Compare commits

...

349 Commits

Author SHA1 Message Date
Jeff Emmett 4306cd6646 feat: add terminal tool with tmux integration
Add interactive terminal windows to canvas dashboard with tmux session management and SSH proxy support.

## Features

- **TerminalShape**: Resizable terminal windows on canvas
- **SessionBrowser**: UI for managing tmux sessions (list, attach, create)
- **TerminalContent**: xterm.js-based terminal renderer with WebSocket streaming
- **TerminalProxy**: SSH connection pooling and tmux command execution
- **Collaboration Mode**: Read-only by default, owner can enable shared input
- **Pin to View**: Keep terminal fixed during pan/zoom

## Implementation

Frontend Components:
- src/shapes/TerminalShapeUtil.tsx - Terminal shape definition
- src/tools/TerminalTool.ts - Shape creation tool
- src/components/TerminalContent.tsx - xterm.js integration with WebSocket
- src/components/SessionBrowser.tsx - tmux session management UI
- Registered in Board.tsx and CustomToolbar.tsx

Backend Infrastructure:
- worker/TerminalProxy.ts - SSH proxy with connection pooling
- terminal-config.example.json - Configuration template

Documentation:
- TERMINAL_SPEC.md - Complete feature specification (19 sections)
- TERMINAL_INTEGRATION.md - Backend setup guide with 2 deployment options

## Dependencies

- @xterm/xterm ^5.5.0 - Terminal emulator
- @xterm/addon-fit ^0.10.0 - Responsive sizing
- @xterm/addon-web-links ^0.11.0 - Clickable URLs
- ssh2 ^1.16.0 - SSH client for backend

## Next Steps

See TERMINAL_INTEGRATION.md for:
- Backend WebSocket server setup on DigitalOcean droplet
- SSH configuration and security hardening
- Testing and troubleshooting procedures

## Notes

- Backend implementation requires separate WebSocket server (Cloudflare Workers lack PTY support)
- Frontend components ready but need backend deployed to function
- Mock data shown in SessionBrowser for development

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 20:48:15 -07:00
Jeff Emmett f4e72452f1 debug: add logging for coordinate defaults during sanitization 2025-11-19 20:25:24 -07:00
Jeff Emmett cae367db28 fix: migrate geo shapes with props.text during Automerge load
Geo shapes saved before the tldraw schema change have props.text which is
no longer valid. This causes ValidationError on page reload when shapes are
loaded from Automerge:

  "ValidationError: At shape(type = geo).props.text: Unexpected property"

The migration logic was only in JSON import (CustomMainMenu.tsx), but shapes
loading from Automerge also need migration.

This fix adds the props.text → props.richText migration to the sanitizeRecord
function in AutomergeToTLStore.ts, ensuring geo shapes are properly migrated
when loaded from Automerge, matching the behavior during JSON import.

The original text is preserved in meta.text for backward compatibility with
search and other features that reference it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 16:25:58 -07:00
Jeff Emmett 7e09d22843 fix: preserve coordinates and convert geo shape text during JSON import
- Fix coordinate collapse bug where shapes were resetting to (0,0)
- Convert geo shape props.text to props.richText (tldraw schema change)
- Preserve text in meta.text for backward compatibility
- Add .nvmrc to enforce Node 20
- Update package.json to require Node >=20.0.0
- Add debug logging for sync and import operations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 16:14:23 -07:00
Jeff Emmett 943162899e fix: use useMemo instead of useState for repo/adapter initialization
Fixed TypeScript error by changing from useState to useMemo for repo and
adapter initialization. This properly exposes the repo and adapter objects
instead of returning a state setter function.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 22:10:55 -07:00
Jeff Emmett e70cba82b4 fix: wait for network adapter to be ready before creating document
Added await for adapter.whenReady() to ensure WebSocket connection is
established before creating the Automerge document. This should enable
the Automerge Repo to properly send binary sync messages when document
changes occur.

Changes:
- Extract adapter from repo initialization to access it
- Wait for adapter.whenReady() before creating document
- Update useEffect dependencies to include adapter and workerUrl

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 22:07:24 -07:00
Jeff Emmett 63b19f7db8 fix: restore working Automerge sync from pre-Cloudflare version
Reverted to the proven approach from commit 90605be where each client
creates its own Automerge document with repo.create(). The Automerge
binary sync protocol handles synchronization between clients through
the WebSocket network adapter, without requiring shared document IDs.

Key changes:
- Each client calls repo.create() to get a unique document
- Initial content loaded from server via HTTP/R2
- Binary sync messages broadcast between clients keep documents in sync
- No need for shared document ID storage/retrieval

This fixes the "Document unavailable" errors and enables real-time
collaboration across multiple board instances.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 21:59:59 -07:00
Jeff Emmett c18d204814 fix: simplify Automerge document creation for concurrent access
Removed document ID storage/retrieval logic that was causing "Document unavailable"
errors. Each client now creates its own Automerge document handle and syncs content
via WebSocket binary protocol. This allows multiple boards to load the same room
simultaneously without conflicts.

- Removed /room/:roomId/documentId endpoints usage
- Each client creates document with repo.create()
- Content syncs via Automerge's native binary sync protocol
- Initial content still loaded from server via HTTP

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 21:40:15 -07:00
Jeff Emmett 3231720004 fix: add await to repo.find() call
repo.find() returns a Promise<DocHandle>, not DocHandle directly.
Added missing await keyword to fix TypeScript build error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 21:26:42 -07:00
Jeff Emmett ca9f250de8 fix: handle concurrent Automerge document access with try-catch
When multiple clients try to load the same room simultaneously, repo.find()
throws "Document unavailable" error if the document isn't in the repo yet.
Wrapped repo.find() in try-catch to create a new handle when document isn't
available, allowing multiple boards to load the same page concurrently.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 21:15:56 -07:00
Jeff Emmett 6ab966cc95 fix: use proper Automerge URL format for repo.find()
The issue was that repo.find() was creating a NEW document instead of
waiting for the existing one to sync from the network.

Changes:
- Use 'automerge:{documentId}' URL format for repo.find()
- Remove try-catch that was creating new documents
- Let repo.find() properly wait for network sync

This ensures all clients use the SAME document ID and can sync in real-time.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 21:00:18 -07:00
Jeff Emmett 02808d170e fix: handle Document unavailable error with try-catch in repo.find()
When repo.find() is called for a document that exists on the server but not
locally, it throws 'Document unavailable' error. This fix:

- Wraps repo.find() in try-catch block
- Falls back to creating new handle if document not found
- Allows sync adapter to merge with server state via network

This handles the case where clients join existing rooms and need to sync
documents from the network.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 20:33:31 -07:00
Jeff Emmett 2cc7c52755 refactor: remove OddJS dependency and fix Automerge sync
Major Changes:
- Fix Automerge "Document unavailable" error by awaiting repo.find()
- Remove @oddjs/odd package and all related dependencies (205 packages)
- Remove location sharing features (OddJS filesystem-dependent)
- Simplify auth to use only CryptoAuthService (WebCryptoAPI-based)

Auth System Changes:
- Refactor AuthService to remove OddJS filesystem integration
- Update AuthContext to remove FileSystem references
- Delete unused auth files (account.ts, backup.ts, linking.ts)
- Delete unused auth components (Register.tsx, LinkDevice.tsx)

Location Features Removed:
- Delete all location components and routes
- Remove LocationShareShape from shape registry
- Clean up location references across codebase

Documentation Updates:
- Update WEBCRYPTO_AUTH.md to remove OddJS references
- Correct component names (CryptoLogin → CryptID)
- Update file structure and dependencies
- Fix Automerge README WebSocket path documentation

Build System:
- Successfully builds without OddJS dependencies
- All TypeScript errors resolved
- Production bundle size optimized

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 20:19:02 -07:00
Jeff Emmett 1e68150b60 fix: implement real-time Automerge sync across clients
- Add document ID coordination via server to ensure all clients sync to same document
- Add new endpoints GET/POST /room/:roomId/documentId for document ID management
- Store automergeDocumentId in Durable Object storage
- Add enhanced logging to CloudflareAdapter send() method for debugging
- Add sharePolicy to Automerge Repo to enable document sharing
- Fix TypeScript errors in useAutomergeSyncRepo

This fixes the issue where each client was creating its own Automerge document
with a unique ID, preventing real-time sync. Now all clients in a room use the
same document ID, enabling proper real-time collaboration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 19:45:36 -07:00
Jeff Emmett dd190824d2 refactor: move wrangler config files to root directory
Moved wrangler.toml and wrangler.dev.toml from worker/ to root directory to fix Cloudflare Pages deployment. Updated package.json scripts to reference new config locations. This resolves the "Missing entry-point to Worker script" error during Pages builds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 19:17:54 -07:00
Jeff Emmett 69ecaf2335 fix: correct worker entry point path in wrangler config files
Update main path from "worker/worker.ts" to "worker.ts" since the wrangler.toml files are located inside the worker/ directory. This fixes the "Missing entry-point to Worker script" error during deployment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 19:05:25 -07:00
Jeff Emmett 693d38eb7d fix: move wrangler.dev.toml to worker/ directory to fix Pages deployment
Cloudflare Pages was detecting wrangler.dev.toml at root level and
switching to Worker deployment mode (running 'npx wrangler deploy')
instead of using the configured build command ('npm run build').

Changes:
- Move wrangler.dev.toml to worker/ directory alongside wrangler.toml
- Update all package.json scripts to reference new location
- Simplify .cfignore since all wrangler configs are now in worker/

This allows Pages to use the correct build command and deploy the
static site with proper routing for /contact and /presentations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 18:43:34 -07:00
Jeff Emmett 7a921d8477
Merge pull request #20 from Jeff-Emmett/add-runpod-AI-API
Add runpod ai api
2025-11-16 18:08:45 -07:00
Jeff Emmett 831d463b5c fix: remove Vercel analytics import from App.tsx
- Remove @vercel/analytics import
- Remove inject() call
- Fixes build error: Cannot find module '@vercel/analytics'

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:57:05 -07:00
Jeff Emmett 8c9eaa1d2b chore: remove Vercel dependencies
- Remove @vercel/analytics package
- Remove vercel CLI package
- Site uses Cloudflare Pages for deployment

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:52:32 -07:00
Jeff Emmett 0882648565 feat: rebrand CryptoLogin to CryptID
- Rename CryptoLogin component to CryptID
- Update all imports and usages across the codebase
- Display 'CryptID: username' in user dropdown menu
- Update UI text to reference CryptID branding
- Update Profile component header to show CryptID
- Update component comments and documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 14:09:23 -07:00
Jeff Emmett f963152238 chore: remove Vercel dependencies and config files
- Remove @vercel/analytics dependency and usage
- Remove vercel CLI dependency
- Delete vercel.json configuration file
- Delete .vercel cache directory
- Site now fully configured for Cloudflare Pages deployment

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 13:35:50 -07:00
Jeff Emmett 99ee87b6d6
Merge pull request #19 from Jeff-Emmett/add-runpod-AI-API
fix: add .cfignore to prevent Pages from using wrangler config
2025-11-16 13:30:06 -07:00
Jeff Emmett c2a56f08e1 fix: add .cfignore to prevent Pages from using wrangler config
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 13:27:07 -07:00
Jeff Emmett e2619f4aae
Merge pull request #18 from Jeff-Emmett/add-runpod-AI-API
fix: remove wrangler.jsonc causing Pages to use wrong deploy method
2025-11-16 05:22:17 -07:00
Jeff Emmett 88a162d6ae fix: remove wrangler.jsonc causing Pages to use wrong deploy method
Remove wrangler.jsonc from root - it was causing Cloudflare Pages to try
deploying via Wrangler instead of using the standard Pages deployment.

Pages should build with npm run build and automatically deploy dist/ directory.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 05:20:28 -07:00
Jeff Emmett fde305abf6
Merge pull request #17 from Jeff-Emmett/add-runpod-AI-API
fix: use repo.create() instead of invalid document ID format
2025-11-16 05:15:49 -07:00
Jeff Emmett 48dac00f59 fix: use repo.create() instead of invalid document ID format
Change from repo.find('automerge:patricia') to repo.create() because
Automerge requires proper UUID-based document IDs, not arbitrary strings.

Each client creates a local document, loads initial data from server,
and syncs via WebSocket. The server syncs documents by room ID, not
by Automerge document ID.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 05:13:37 -07:00
Jeff Emmett caf831cce7
Merge pull request #16 from Jeff-Emmett/add-runpod-AI-API
Add runpod ai api
2025-11-16 05:07:29 -07:00
Jeff Emmett 776526c65e fix: add type cast for currentDoc to fix TypeScript error
Cast handle.doc() to any to fix TypeScript error about missing 'store' property.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 05:04:54 -07:00
Jeff Emmett 64d4a65613 fix: await repo.find() to fix TypeScript build errors
repo.find() returns a Promise<DocHandle>, so we need to await it.
This fixes the TypeScript compilation errors in the build.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 05:00:04 -07:00
Jeff Emmett 432e90d9ef fix: enable production logging for R2 persistence debugging
Add console logs in production to debug why shapes aren't being saved to R2.
This will help identify if saves are:
- Being triggered
- Being deferred/skipped
- Successfully completing

Logs added:
- 💾 When persistence starts
-  When persistence succeeds
- 🔍 When shape patches are detected
- 🚫 When saves are skipped (ephemeral/pinned changes)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 04:56:32 -07:00
Jeff Emmett 52c1af6864 fix: add wrangler.jsonc for Pages static asset deployment
Configure Cloudflare Pages to deploy the dist directory as static assets.
This fixes the deployment error "Missing entry-point to Worker script".

The frontend (static assets) will be served by Pages while the backend
(WebSocket server, Durable Objects) runs separately as a Worker.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 04:33:22 -07:00
Jeff Emmett 13ceb2ebbd
Merge pull request #15 from Jeff-Emmett/add-runpod-AI-API
Add runpod ai api
2025-11-16 04:21:51 -07:00
Jeff Emmett 71e7e5de05 fix: remove ImageGen references to fix build
Remove ImageGenShape import and references from useAutomergeStoreV2.ts
to fix TypeScript build error. ImageGen feature files are not yet committed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 04:17:49 -07:00
Jeff Emmett 9b7cde262a fix: move Worker config to separate file for Pages compatibility
Move wrangler.toml to worker/wrangler.toml to separate Worker and Pages configurations.
Cloudflare Pages was trying to read wrangler.toml and failing because it contained
Worker-specific configuration (Durable Objects, migrations, etc.) that Pages doesn't support.

Changes:
- Move wrangler.toml → worker/wrangler.toml
- Update deploy scripts to use --config worker/wrangler.toml
- Pages deployment now uses Cloudflare dashboard configuration only

This resolves the deployment error:
"Configuration file cannot contain both 'main' and 'pages_build_output_dir'"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 04:14:40 -07:00
Jeff Emmett f0f7c47775 fix: add pages_build_output_dir to wrangler.toml
Add Cloudflare Pages configuration to wrangler.toml to resolve deployment warning.
This tells Cloudflare Pages where to find the built static files (dist directory).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 04:07:32 -07:00
Jeff Emmett 90605bee09 fix: enable real-time multiplayer sync for automerge
Add manual sync triggering to broadcast document changes to other peers in real-time.
The Automerge Repo wasn't auto-broadcasting because the WebSocket setup doesn't use peer discovery.

Changes:
- Add triggerSync() helper function to manually trigger sync broadcasts
- Call triggerSync() after all document changes (position updates, eraser changes, regular changes)
- Pass Automerge document to patch handlers to prevent coordinate loss
- Add ImageGenShape support to schema

This fixes the issue where changes were being saved to Automerge locally but not
broadcast to other connected clients until page reload.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 04:00:31 -07:00
Jeff Emmett 0de17476d5
Merge pull request #14 from Jeff-Emmett/add-runpod-AI-API
fix custom shape type validation errors
2025-11-16 03:15:51 -07:00
Jeff Emmett 7a17b0944a fix custom shape type validation errors
Add case normalization for custom shape types to prevent validation errors when loading shapes with lowercase type names (e.g., "chatBox" → "ChatBox"). The TLDraw schema expects PascalCase type names for custom shapes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 03:13:07 -07:00
Jeff Emmett cb5045984b
Merge pull request #13 from Jeff-Emmett/add-runpod-AI-API
prevent coordinate collapse on reload
2025-11-16 03:08:07 -07:00
Jeff Emmett f5582fc7d1 prevent coordinate collapse on reload
🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

4
.cfignore Normal file
View File

@ -0,0 +1,4 @@
# Ignore Cloudflare Worker configuration files during Pages deployment
# These are only used for separate Worker deployments
worker/
*.toml

13
.env.example Normal file
View File

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

5
.gitattributes vendored Normal file
View File

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

View File

@ -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 }}

60
.github/workflows/quartz-sync.yml vendored Normal file
View File

@ -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 }}"

177
.gitignore vendored Normal file
View File

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

3
.npmrc Normal file
View File

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

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
20

4
.prettierrc.json Normal file
View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -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

37
CLOUDFLARE_PAGES_SETUP.md Normal file
View File

@ -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.

101
CLOUDFLARE_WORKER_SETUP.md Normal file
View File

@ -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

185
DATA_CONVERSION_GUIDE.md Normal file
View File

@ -0,0 +1,185 @@
# 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
- 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

141
DATA_CONVERSION_SUMMARY.md Normal file
View File

@ -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

145
DATA_SAFETY_VERIFICATION.md Normal file
View File

@ -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.

92
DEPLOYMENT_GUIDE.md Normal file
View File

@ -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.

100
DEPLOYMENT_SUMMARY.md Normal file
View File

@ -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

142
FATHOM_INTEGRATION.md Normal file
View File

@ -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

75
GESTURES.md Normal file
View File

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

79
MIGRATION_CHECKLIST.md Normal file
View File

@ -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

232
QUARTZ_SYNC_SETUP.md Normal file
View File

@ -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/)

3
README.md Normal file
View File

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

View File

@ -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

646
TERMINAL_INTEGRATION.md Normal file
View File

@ -0,0 +1,646 @@
# Terminal Feature Integration Guide
## Overview
This document provides step-by-step instructions for integrating the terminal feature with the backend infrastructure. The terminal feature requires WebSocket support and SSH proxy capabilities that cannot run directly in Cloudflare Workers due to PTY limitations.
---
## Backend Architecture Decision
Since Cloudflare Workers cannot create PTY (pseudo-terminal) processes required for tmux, you have **two implementation options**:
### Option 1: Separate WebSocket Server (Recommended)
Run a Node.js WebSocket server on your DigitalOcean droplet that handles terminal connections.
**Pros:**
- Clean separation of concerns
- Full control over PTY/tmux integration
- No Cloudflare Worker modifications needed
- Better security (SSH keys never leave your droplet)
**Cons:**
- Additional server to maintain
- Need to expose WebSocket port
### Option 2: Hybrid Cloudflare + Droplet Service
Use Cloudflare Durable Objects to proxy WebSocket connections to a backend service on your droplet.
**Pros:**
- Leverages existing Cloudflare infrastructure
- Can reuse authentication
- Single entry point for clients
**Cons:**
- More complex setup
- Still requires separate service on droplet
- May have latency overhead
---
## Option 1: Separate WebSocket Server (Step-by-Step)
### Step 1: Create WebSocket Server on Droplet
Create a new file on your DigitalOcean droplet: `/opt/terminal-server/server.js`
```javascript
import WebSocket from 'ws'
import { TerminalProxyManager, SSHConfig } from './TerminalProxy.js'
const PORT = 8080
const wss = new WebSocket.Server({ port: PORT })
// Load SSH config from environment or config file
const sshConfig: SSHConfig = {
host: 'localhost', // Connect to same droplet
port: 22,
username: process.env.SSH_USER || 'canvas-terminal',
privateKey: fs.readFileSync(process.env.SSH_KEY_PATH || '/opt/terminal-server/key')
}
const proxyManager = new TerminalProxyManager()
console.log(`Terminal WebSocket server listening on port ${PORT}`)
wss.on('connection', (ws, req) => {
const url = new URL(req.url, `ws://localhost:${PORT}`)
const sessionId = url.pathname.split('/').pop()
// TODO: Add authentication
const userId = req.headers['x-user-id'] || 'anonymous'
console.log(`Client connected: ${userId}`)
const proxy = proxyManager.getProxy(userId, sshConfig)
let currentSession: string | null = null
ws.on('message', async (data) => {
try {
const message = JSON.parse(data.toString())
switch (message.type) {
case 'init':
// Attach to tmux session
const connectionId = `${userId}-conn`
if (!proxy.isConnected(connectionId)) {
await proxy.connect(connectionId)
}
currentSession = await proxy.attachSession(
connectionId,
message.sessionId,
message.cols || 80,
message.rows || 24,
(output) => {
ws.send(JSON.stringify({ type: 'output', data: output }))
},
() => {
ws.send(JSON.stringify({ type: 'status', status: 'disconnected' }))
}
)
ws.send(JSON.stringify({ type: 'status', status: 'connected' }))
break
case 'input':
if (currentSession) {
await proxy.sendInput(currentSession, message.data)
}
break
case 'resize':
if (currentSession) {
await proxy.resize(currentSession, message.cols, message.rows)
}
break
case 'list_sessions':
const connectionId2 = `${userId}-conn`
if (!proxy.isConnected(connectionId2)) {
await proxy.connect(connectionId2)
}
const sessions = await proxy.listSessions(connectionId2)
ws.send(JSON.stringify({ type: 'sessions', sessions }))
break
case 'create_session':
const connectionId3 = `${userId}-conn`
if (!proxy.isConnected(connectionId3)) {
await proxy.connect(connectionId3)
}
const newSession = await proxy.createSession(connectionId3, message.name)
ws.send(JSON.stringify({ type: 'session_created', sessionId: newSession }))
break
case 'detach':
if (currentSession) {
await proxy.detachSession(currentSession)
currentSession = null
ws.send(JSON.stringify({ type: 'status', status: 'detached' }))
}
break
}
} catch (err) {
console.error('Error handling message:', err)
ws.send(JSON.stringify({ type: 'error', message: err.message }))
}
})
ws.on('close', async () => {
console.log(`Client disconnected: ${userId}`)
if (currentSession) {
await proxy.detachSession(currentSession)
}
})
ws.on('error', (err) => {
console.error('WebSocket error:', err)
})
})
// Cleanup on shutdown
process.on('SIGINT', async () => {
console.log('Shutting down...')
await proxyManager.cleanup()
wss.close()
process.exit(0)
})
```
### Step 2: Copy TerminalProxy.ts to Droplet
Copy `/worker/TerminalProxy.ts` to your droplet and convert it to work with Node.js:
```bash
# On your local machine
scp worker/TerminalProxy.ts your-droplet:/opt/terminal-server/TerminalProxy.js
```
### Step 3: Install Dependencies on Droplet
```bash
ssh your-droplet
cd /opt/terminal-server
npm init -y
npm install ws ssh2
```
### Step 4: Create systemd Service
Create `/etc/systemd/system/terminal-server.service`:
```ini
[Unit]
Description=Terminal WebSocket Server
After=network.target
[Service]
Type=simple
User=canvas-terminal
WorkingDirectory=/opt/terminal-server
Environment="NODE_ENV=production"
Environment="SSH_USER=canvas-terminal"
Environment="SSH_KEY_PATH=/opt/terminal-server/key"
ExecStart=/usr/bin/node /opt/terminal-server/server.js
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl enable terminal-server
sudo systemctl start terminal-server
sudo systemctl status terminal-server
```
### Step 5: Configure Firewall
```bash
# Allow WebSocket connections
sudo ufw allow 8080/tcp
# Or if using specific IPs
sudo ufw allow from YOUR_CLOUDFLARE_IP to any port 8080
```
### Step 6: Update Frontend WebSocket URL
Modify `/src/components/TerminalContent.tsx`:
```typescript
const connectWebSocket = () => {
// Update with your droplet IP
const wsUrl = `wss://YOUR_DROPLET_IP:8080/terminal/${sessionId}`
const ws = new WebSocket(wsUrl)
// ... rest of code
}
```
### Step 7: Optional - Use nginx as Reverse Proxy
Create `/etc/nginx/sites-available/terminal-ws`:
```nginx
upstream terminal_backend {
server 127.0.0.1:8080;
}
server {
listen 443 ssl http2;
server_name terminal.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
location / {
proxy_pass http://terminal_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket specific
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
}
```
Enable and reload:
```bash
sudo ln -s /etc/nginx/sites-available/terminal-ws /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
---
## Option 2: Cloudflare Worker Integration
If you prefer to proxy through Cloudflare, add these routes to `worker/AutomergeDurableObject.ts`:
```typescript
import { TerminalProxyManager } from './TerminalProxy'
export class AutomergeDurableObject {
// Add to existing class
private terminalProxyManager: TerminalProxyManager | null = null
private getTerminalProxy() {
if (!this.terminalProxyManager) {
this.terminalProxyManager = new TerminalProxyManager()
}
return this.terminalProxyManager
}
// Add to router (after line 155)
private readonly router = AutoRouter({
// ... existing routes ...
})
// ... existing routes ...
// Terminal WebSocket endpoint
.get("/terminal/ws/:sessionId", async (request) => {
const upgradeHeader = request.headers.get("Upgrade")
if (upgradeHeader !== "websocket") {
return new Response("Expected Upgrade: websocket", { status: 426 })
}
const [client, server] = Object.values(new WebSocketPair())
// Handle WebSocket connection
server.accept()
const proxyManager = this.getTerminalProxy()
const userId = "user-123" // TODO: Get from auth
// Get SSH config from environment or secrets
const sshConfig = {
host: request.env.TERMINAL_SSH_HOST,
port: 22,
username: request.env.TERMINAL_SSH_USER,
privateKey: request.env.TERMINAL_SSH_KEY
}
const proxy = proxyManager.getProxy(userId, sshConfig)
let currentSession: string | null = null
server.addEventListener("message", async (event) => {
try {
const message = JSON.parse(event.data as string)
// Handle message types similar to Option 1
// ... (implementation same as server.js above)
} catch (err) {
server.send(JSON.stringify({ type: "error", message: err.message }))
}
})
return new Response(null, {
status: 101,
webSocket: client
})
})
// List tmux sessions
.get("/terminal/sessions", async (request) => {
const userId = "user-123" // TODO: Get from auth
const proxyManager = this.getTerminalProxy()
const sshConfig = {
host: request.env.TERMINAL_SSH_HOST,
port: 22,
username: request.env.TERMINAL_SSH_USER,
privateKey: request.env.TERMINAL_SSH_KEY
}
const proxy = proxyManager.getProxy(userId, sshConfig)
const connectionId = `${userId}-conn`
if (!proxy.isConnected(connectionId)) {
await proxy.connect(connectionId)
}
const sessions = await proxy.listSessions(connectionId)
return new Response(JSON.stringify({ sessions }), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
})
})
// Create new tmux session
.post("/terminal/sessions", async (request) => {
const userId = "user-123" // TODO: Get from auth
const { name } = await request.json() as { name: string }
const proxyManager = this.getTerminalProxy()
const sshConfig = {
host: request.env.TERMINAL_SSH_HOST,
port: 22,
username: request.env.TERMINAL_SSH_USER,
privateKey: request.env.TERMINAL_SSH_KEY
}
const proxy = proxyManager.getProxy(userId, sshConfig)
const connectionId = `${userId}-conn`
if (!proxy.isConnected(connectionId)) {
await proxy.connect(connectionId)
}
const sessionId = await proxy.createSession(connectionId, name)
return new Response(JSON.stringify({ sessionId }), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
})
})
}
```
**Note:** Cloudflare Workers have limitations:
- 128MB memory limit
- 30-second CPU time limit (50ms for free tier)
- ssh2 may not work due to crypto limitations
**Recommendation:** Use Option 1 (separate WebSocket server) for better reliability.
---
## Environment Variables
Add to `.env` or Cloudflare Worker secrets:
```bash
TERMINAL_SSH_HOST=165.227.XXX.XXX
TERMINAL_SSH_PORT=22
TERMINAL_SSH_USER=canvas-terminal
TERMINAL_SSH_KEY="-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----"
```
Set Cloudflare secrets:
```bash
wrangler secret put TERMINAL_SSH_HOST
wrangler secret put TERMINAL_SSH_USER
wrangler secret put TERMINAL_SSH_KEY
```
---
## Testing
### 1. Test WebSocket Server
```bash
# Install wscat
npm install -g wscat
# Connect to server
wscat -c ws://YOUR_DROPLET_IP:8080/terminal/test-session
# Send test message
> {"type":"list_sessions"}
```
### 2. Test from Browser Console
```javascript
const ws = new WebSocket('wss://YOUR_DROPLET_IP:8080/terminal/test-session')
ws.onopen = () => {
console.log('Connected')
ws.send(JSON.stringify({ type: 'list_sessions' }))
}
ws.onmessage = (event) => {
console.log('Received:', JSON.parse(event.data))
}
```
### 3. Test Terminal Creation in Canvas
1. Open canvas dashboard
2. Click terminal button in toolbar
3. Should see session browser
4. Click "Create New Session" or attach to existing
5. Should see terminal prompt
---
## Troubleshooting
### WebSocket Connection Failed
**Check server is running:**
```bash
sudo systemctl status terminal-server
sudo journalctl -u terminal-server -f
```
**Check firewall:**
```bash
sudo ufw status
telnet YOUR_DROPLET_IP 8080
```
**Check nginx (if using):**
```bash
sudo nginx -t
sudo tail -f /var/log/nginx/error.log
```
### SSH Connection Failed
**Test SSH manually:**
```bash
ssh -i /opt/terminal-server/key canvas-terminal@localhost
```
**Check SSH key permissions:**
```bash
chmod 600 /opt/terminal-server/key
chown canvas-terminal:canvas-terminal /opt/terminal-server/key
```
**Check authorized_keys:**
```bash
cat /home/canvas-terminal/.ssh/authorized_keys
```
### tmux Commands Not Working
**Test tmux manually:**
```bash
tmux ls
tmux new-session -d -s test
tmux attach -t test
```
**Install tmux if missing:**
```bash
sudo apt update
sudo apt install tmux
```
### Browser Console Errors
**Mixed content (HTTP/HTTPS):**
- Ensure WebSocket uses `wss://` not `ws://`
- Use HTTPS for canvas dashboard
- Use SSL certificate for WebSocket server
**CORS errors:**
- Check nginx/server CORS headers
- Verify origin matches
---
## Security Hardening
### 1. Restrict SSH Key
Create dedicated key for terminal server:
```bash
ssh-keygen -t ed25519 -f /opt/terminal-server/key -N ""
```
Add to droplet's `authorized_keys` with command restriction:
```bash
command="/usr/bin/tmux" ssh-ed25519 AAAA... canvas-terminal
```
### 2. Use Restricted Shell
Edit `/home/canvas-terminal/.bashrc`:
```bash
# Only allow tmux
if [[ $- == *i* ]]; then
exec tmux attach || exec tmux
fi
```
### 3. Rate Limiting
Add to nginx config:
```nginx
limit_req_zone $binary_remote_addr zone=terminal:10m rate=10r/s;
server {
location / {
limit_req zone=terminal burst=20;
# ... proxy config ...
}
}
```
### 4. Authentication
Add JWT validation in WebSocket server:
```javascript
import jwt from 'jsonwebtoken'
wss.on('connection', (ws, req) => {
const token = req.headers['authorization']?.split(' ')[1]
try {
const payload = jwt.verify(token, JWT_SECRET)
const userId = payload.userId
// ... rest of code ...
} catch (err) {
ws.close(1008, 'Unauthorized')
return
}
})
```
---
## Next Steps
1. Choose Option 1 or Option 2
2. Set up backend server/routes
3. Configure SSH credentials
4. Test WebSocket connection
5. Test terminal creation in canvas
6. Add authentication
7. Deploy to production
---
## Additional Resources
- [ssh2 documentation](https://github.com/mscdex/ssh2)
- [ws (WebSocket) documentation](https://github.com/websockets/ws)
- [tmux manual](https://github.com/tmux/tmux/wiki)
- [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/)
- [nginx WebSocket proxying](https://nginx.org/en/docs/http/websocket.html)
---
**Last Updated:** 2025-01-19
**Status:** Implementation guide for terminal feature backend

1232
TERMINAL_SPEC.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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]

60
TRANSCRIPTION_SETUP.md Normal file
View File

@ -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

93
WORKER_ENV_GUIDE.md Normal file
View File

@ -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

14
_redirects Normal file
View File

@ -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

View File

@ -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.*

View File

@ -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

171
docs/TRANSCRIPTION_TOOL.md Normal file
View File

@ -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.*

304
docs/WEBCRYPTO_AUTH.md Normal file
View File

@ -0,0 +1,304 @@
# 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. This is the primary authentication mechanism for the application.
## 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. **AuthService** (`src/lib/auth/authService.ts`)
- Simplified authentication service
- Session management
- Integration with CryptoAuthService
4. **UI Components**
- `CryptID.tsx` - Cryptographic authentication UI
- `CryptoDebug.tsx` - Debug component for verification
- `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
- **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 in localStorage
5. **Input Validation**: Username format validation
6. **Error Handling**: Comprehensive error management
### ⚠️ Security Notes
1. **Private Key Storage**: Currently uses localStorage for demo purposes
- In production, consider using Web Crypto API's non-extractable keys
- Consider hardware security modules (HSM)
- Implement proper key derivation
2. **Session Management**:
- Uses localStorage for session persistence
- Consider implementing JWT tokens for server-side verification
- Add session expiration and refresh logic
3. **Network Security**:
- All crypto operations happen client-side
- No private keys transmitted over network
- Consider adding server-side signature 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();
// AuthService automatically uses crypto auth
const success = await login('username');
```
### Using the CryptID Component
```typescript
import CryptID from './components/auth/CryptID';
// Render the authentication component
<CryptID
onSuccess={() => console.log('Login successful')}
onCancel={() => console.log('Login cancelled')}
/>
```
### 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 # Simplified auth service
│ │ ├── sessionPersistence.ts # Session storage utilities
│ │ └── types.ts # TypeScript types
│ └── utils/
│ └── browser.ts # Browser support detection
├── components/
│ └── auth/
│ ├── CryptID.tsx # Main crypto auth UI
│ ├── CryptoDebug.tsx # Debug component
│ └── CryptoTest.tsx # Test component
├── context/
│ └── AuthContext.tsx # React context for auth state
└── css/
└── crypto-auth.css # Styles for crypto components
```
## Dependencies
### Required Packages
- `one-webcrypto`: WebCryptoAPI polyfill (^1.0.3)
### Browser APIs Used
- `window.crypto.subtle`: WebCryptoAPI
- `window.localStorage`: Key and session storage
- `window.isSecureContext`: Security context check
## Storage
### localStorage Keys Used
- `registeredUsers`: Array of registered usernames
- `${username}_publicKey`: User's public key (Base64)
- `${username}_authData`: Authentication data (challenge, signature, timestamp)
- `session`: Current user session data
## 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
- [x] Session persistence
## 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 in localStorage
- Check stored credentials
- Clear browser data and retry
### Debug Mode
Enable debug logging by opening the browser console:
```typescript
localStorage.setItem('debug_crypto', 'true');
```
## Future Enhancements
### Planned Improvements
1. **Enhanced Key Storage**: Use Web Crypto API's non-extractable keys
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
## Integration with Automerge Sync
The authentication system works seamlessly with the Automerge-based real-time collaboration:
- **User Identification**: Each user is identified by their username in Automerge
- **Session Management**: Sessions persist across page reloads via localStorage
- **Collaboration**: Authenticated users can join shared canvas rooms
- **Privacy**: Only authenticated users can access canvas data
## 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)

125
github-integration-setup.md Normal file
View File

@ -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

44
index.html Normal file
View File

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

15345
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

89
package.json Normal file
View File

@ -0,0 +1,89 @@
{
"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",
"@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",
"@xenova/transformers": "^2.17.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"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",
"ssh2": "^1.17.0",
"tldraw": "^3.15.4",
"use-whisper": "^0.0.1",
"webcola": "^3.4.0",
"webnative": "^0.36.3"
},
"devDependencies": {
"@cloudflare/types": "^6.0.0",
"@cloudflare/workers-types": "^4.20240821.1",
"@types/lodash.throttle": "^4",
"@types/rbush": "^4.0.0",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"@vitejs/plugin-react": "^4.0.3",
"concurrently": "^9.1.0",
"typescript": "^5.6.3",
"vite": "^6.0.3",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
"wrangler": "^4.33.2"
},
"engines": {
"node": ">=20.0.0"
}
}

24
quartz-sync.env.example Normal file
View File

@ -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

161
src/App.tsx Normal file
View File

@ -0,0 +1,161 @@
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 { createRoot } from "react-dom/client"
import { DailyProvider } from "@daily-co/daily-react"
import Daily from "@daily-co/daily-js"
import "tldraw/tldraw.css";
import "@/css/style.css";
import "@/css/auth.css"; // Import auth styles
import "@/css/crypto-auth.css"; // Import crypto auth styles
import "@/css/starred-boards.css"; // Import starred boards styles
import "@/css/user-profile.css"; // Import user profile styles
import { Dashboard } from "./routes/Dashboard";
import { useState, useEffect } from 'react';
// Import React Context providers
import { AuthProvider, useAuth } from './context/AuthContext';
import { FileSystemProvider } from './context/FileSystemContext';
import { NotificationProvider } from './context/NotificationContext';
import NotificationsDisplay from './components/NotificationsDisplay';
import { ErrorBoundary } from './components/ErrorBoundary';
// Import auth components
import CryptID from './components/auth/CryptID';
import CryptoDebug from './components/auth/CryptoDebug';
// 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">
<CryptID onSuccess={() => window.location.href = '/'} />
</div>
);
};
return (
<ErrorBoundary>
<AuthProvider>
<FileSystemProvider>
<NotificationProvider>
<DailyProvider callObject={callObject}>
<BrowserRouter>
{/* Display notifications */}
<NotificationsDisplay />
<Routes>
{/* Auth routes */}
<Route path="/login" element={<AuthPage />} />
{/* Optional auth routes */}
<Route path="/" element={
<OptionalAuthRoute>
<Default />
</OptionalAuthRoute>
} />
<Route path="/contact" element={
<OptionalAuthRoute>
<Contact />
</OptionalAuthRoute>
} />
<Route path="/board/:slug" element={
<OptionalAuthRoute>
<Board />
</OptionalAuthRoute>
} />
<Route path="/inbox" element={
<OptionalAuthRoute>
<Inbox />
</OptionalAuthRoute>
} />
<Route path="/debug" element={
<OptionalAuthRoute>
<CryptoDebug />
</OptionalAuthRoute>
} />
<Route path="/dashboard" element={
<OptionalAuthRoute>
<Dashboard />
</OptionalAuthRoute>
} />
<Route path="/presentations" element={
<OptionalAuthRoute>
<Presentations />
</OptionalAuthRoute>
} />
<Route path="/presentations/resilience" element={
<OptionalAuthRoute>
<Resilience />
</OptionalAuthRoute>
} />
</Routes>
</BrowserRouter>
</DailyProvider>
</NotificationProvider>
</FileSystemProvider>
</AuthProvider>
</ErrorBoundary>
);
};
// Initialize the app
createRoot(document.getElementById("root")!).render(<AppWithProviders />);
export default AppWithProviders;

290
src/CmdK.tsx Normal file
View File

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

512
src/GestureTool.ts Normal file
View File

@ -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)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,497 @@
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()
// CRITICAL FIX: JSON.stringify's second parameter when it's an array is a replacer
// that only includes those properties. We need to stringify the entire store object.
// To ensure stable ordering, create a new object with sorted keys
const sortedStore: any = {}
for (const key of storeKeys) {
sortedStore[key] = storeData[key]
}
const storeString = JSON.stringify(sortedStore)
// 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)', {
dataLength: (message as any).data.byteLength,
documentId: (message as any).documentId,
targetId: message.targetId
})
// Send binary data directly for Automerge's native sync protocol
this.websocket.send((message as any).data)
} else if (message.type === 'sync' && (message as any).data instanceof Uint8Array) {
console.log('📤 CloudflareAdapter: Sending Uint8Array sync message (Automerge protocol)', {
dataLength: (message as any).data.length,
documentId: (message as any).documentId,
targetId: message.targetId
})
// 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))
}
} else {
console.warn('⚠️ CloudflareAdapter: Cannot send message - WebSocket not open', {
messageType: message.type,
readyState: this.websocket?.readyState
})
}
}
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
}
}
}

View File

@ -0,0 +1,65 @@
// 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 = {}
// Validate and fix index property - must be a valid IndexKey (like 'a1', 'a2', etc.)
if (!sanitized.index || typeof sanitized.index !== 'string' || !/^[a-z]\d+$/.test(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
}

52
src/automerge/README.md Normal file
View File

@ -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. The CloudflareAdapter will automatically connect to `/connect/{roomId}` via WebSocket
The migration is backward compatible - the system will handle both legacy and new document formats.

View File

@ -0,0 +1,616 @@
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: Extract arrow text BEFORE deep copy to handle RichText instances properly
// Arrow text should be a string, but might be a RichText object in edge cases
let arrowTextValue: any = undefined
if (sanitized.type === 'arrow') {
try {
const props = sanitized.props || {}
if ('text' in props) {
try {
// Use Object.getOwnPropertyDescriptor to safely check if it's a getter
const descriptor = Object.getOwnPropertyDescriptor(props, 'text')
let textValue: any = undefined
if (descriptor && descriptor.get) {
// It's a getter - try to call it safely
try {
textValue = descriptor.get.call(props)
} catch (getterError) {
console.warn(`🔧 TLStoreToAutomerge: Error calling text getter for arrow ${sanitized.id}:`, getterError)
textValue = undefined
}
} else {
// It's a regular property - access it directly
textValue = (props as any).text
}
// Now process the value
if (textValue !== undefined && textValue !== null) {
// If it's a string, use it directly
if (typeof textValue === 'string') {
arrowTextValue = textValue
}
// If it's a RichText object, extract the text content
else if (typeof textValue === 'object' && textValue !== null) {
// Try to extract text from RichText object
try {
const serialized = JSON.parse(JSON.stringify(textValue))
// If it has content array, extract text from it
if (Array.isArray(serialized.content)) {
// Extract text from RichText content
const extractText = (content: any[]): string => {
return content.map((item: any) => {
if (item.type === 'text' && item.text) {
return item.text
} else if (item.content && Array.isArray(item.content)) {
return extractText(item.content)
}
return ''
}).join('')
}
arrowTextValue = extractText(serialized.content)
} else {
// Fallback: try to get text property
arrowTextValue = serialized.text || ''
}
} catch (serializeError) {
// If serialization fails, try to extract manually
if ((textValue as any).text && typeof (textValue as any).text === 'string') {
arrowTextValue = (textValue as any).text
} else {
arrowTextValue = String(textValue)
}
}
}
// For other types, convert to string
else {
arrowTextValue = String(textValue)
}
}
} catch (e) {
console.warn(`🔧 TLStoreToAutomerge: Error extracting text for arrow ${sanitized.id}:`, e)
arrowTextValue = undefined
}
}
} catch (e) {
console.warn(`🔧 TLStoreToAutomerge: Error checking text for arrow ${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 and arrow text temporarily to avoid serialization issues
try {
const propsWithoutSpecial: any = {}
// Copy all props except richText and arrow text (if extracted)
for (const key in sanitized.props) {
if (key !== 'richText' && !(sanitized.type === 'arrow' && key === 'text' && arrowTextValue !== undefined)) {
propsWithoutSpecial[key] = (sanitized.props as any)[key]
}
}
sanitized.props = JSON.parse(JSON.stringify(propsWithoutSpecial))
} 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
}
if (arrowTextValue !== undefined) {
delete (sanitized.props as any).text
}
}
// 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: Restore extracted text value if available, otherwise preserve existing text
if (arrowTextValue !== undefined) {
// Use the extracted text value (handles RichText objects by extracting text content)
(sanitized.props as any).text = arrowTextValue
} else {
// 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

View File

@ -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
}
},
}

11
src/automerge/index.ts Normal file
View File

@ -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"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
export { useAutomergeSync } from "./useAutomergeSyncRepo"

View File

@ -0,0 +1,620 @@
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, parseAutomergeUrl, stringifyAutomergeUrl } 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)
const lastSentHashRef = useRef<string | null>(null)
const isMouseActiveRef = useRef<boolean>(false)
const pendingSaveRef = useRef<boolean>(false)
const saveFunctionRef = useRef<(() => void) | null>(null)
// Generate a fast hash of the document state for change detection
// OPTIMIZED: Avoid expensive JSON.stringify, use lightweight checksums instead
const generateDocHash = useCallback((doc: any): string => {
if (!doc || !doc.store) return ''
const storeData = doc.store || {}
const storeKeys = Object.keys(storeData).sort()
// Fast hash using record IDs and lightweight checksums
// Instead of JSON.stringify, use a combination of ID, type, and key property values
let hash = 0
for (const key of storeKeys) {
// Skip ephemeral records
if (key.startsWith('instance:') ||
key.startsWith('instance_page_state:') ||
key.startsWith('instance_presence:') ||
key.startsWith('camera:') ||
key.startsWith('pointer:')) {
continue
}
const record = storeData[key]
if (!record) continue
// Use lightweight hash: ID + typeName + type (if shape) + key properties
let recordHash = key
if (record.typeName) recordHash += record.typeName
if (record.type) recordHash += record.type
// For shapes, include x, y, w, h for position/size changes
if (record.typeName === 'shape') {
if (typeof record.x === 'number') recordHash += `x${record.x}`
if (typeof record.y === 'number') recordHash += `y${record.y}`
if (typeof record.props?.w === 'number') recordHash += `w${record.props.w}`
if (typeof record.props?.h === 'number') recordHash += `h${record.props.h}`
}
// Simple hash of the record string
for (let i = 0; i < recordHash.length; i++) {
const char = recordHash.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash
}
}
return hash.toString(36)
}, [])
// 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, adapter } = useMemo(() => {
const adapter = new CloudflareNetworkAdapter(workerUrl, roomId, applyJsonSyncData)
const repo = new Repo({
network: [adapter],
// Enable sharing of all documents with all peers
sharePolicy: async () => true
})
// Log when sync messages are sent/received
adapter.on('message', (msg: any) => {
console.log('🔄 CloudflareAdapter received message from network:', msg.type)
})
return { repo, adapter }
}, [workerUrl, roomId, applyJsonSyncData])
// Initialize Automerge document handle
useEffect(() => {
let mounted = true
const initializeHandle = async () => {
try {
console.log("🔌 Initializing Automerge Repo with NetworkAdapter for room:", roomId)
// CRITICAL: Wait for the network adapter to be ready before creating document
// This ensures the WebSocket connection is established for sync
console.log("⏳ Waiting for network adapter to be ready...")
await adapter.whenReady()
console.log("✅ Network adapter is ready, WebSocket connected")
if (mounted) {
// CRITICAL: Create a new Automerge document (repo.create() generates a proper document ID)
// Each client gets its own document, but Automerge sync protocol keeps them in sync
// The network adapter broadcasts sync messages between all clients in the same room
const handle = repo.create<TLStoreSnapshot>()
console.log("Created Automerge handle via Repo:", {
handleId: handle.documentId,
isReady: handle.isReady(),
roomId: roomId
})
// 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
}
console.log("Found/Created Automerge handle via Repo:", {
handleId: handle.documentId,
isReady: handle.isReady(),
roomId: roomId
})
// Wait for the handle to be ready
await handle.whenReady()
// Initialize document with default store if it's new/empty
const currentDoc = handle.doc() as any
if (!currentDoc || !currentDoc.store || Object.keys(currentDoc.store).length === 0) {
console.log("📝 Document is new/empty - initializing with default store")
// Try to load initial data from server for new documents
try {
const response = await fetch(`${workerUrl}/room/${roomId}`)
if (response.ok) {
const serverDoc = await response.json() as TLStoreSnapshot
const serverRecordCount = Object.keys(serverDoc.store || {}).length
if (serverDoc.store && serverRecordCount > 0) {
console.log(`📥 Loading ${serverRecordCount} records from server into new document`)
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 - document will start empty")
}
} 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 - document will start empty and sync via WebSocket
}
} else {
const existingRecordCount = Object.keys(currentDoc.store || {}).length
console.log(`✅ Document already has ${existingRecordCount} records - ready to sync`)
}
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, adapter, roomId, workerUrl])
// Track mouse state to prevent persistence during active mouse interactions
useEffect(() => {
const handleMouseDown = () => {
isMouseActiveRef.current = true
}
const handleMouseUp = () => {
isMouseActiveRef.current = false
// If there was a pending save, schedule it now that mouse is released
if (pendingSaveRef.current) {
pendingSaveRef.current = false
// Trigger save after a short delay to ensure mouse interaction is fully complete
setTimeout(() => {
// The save will be triggered by the next scheduled save or change event
// We just need to ensure the mouse state is cleared
}, 50)
}
}
// Also track touch events for mobile
const handleTouchStart = () => {
isMouseActiveRef.current = true
}
const handleTouchEnd = () => {
isMouseActiveRef.current = false
if (pendingSaveRef.current) {
pendingSaveRef.current = false
}
}
// Add event listeners to document to catch all mouse interactions
document.addEventListener('mousedown', handleMouseDown, { capture: true })
document.addEventListener('mouseup', handleMouseUp, { capture: true })
document.addEventListener('touchstart', handleTouchStart, { capture: true })
document.addEventListener('touchend', handleTouchEnd, { capture: true })
return () => {
document.removeEventListener('mousedown', handleMouseDown, { capture: true })
document.removeEventListener('mouseup', handleMouseUp, { capture: true })
document.removeEventListener('touchstart', handleTouchStart, { capture: true })
document.removeEventListener('touchend', handleTouchEnd, { capture: true })
}
}, [])
// 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 () => {
// CRITICAL: Don't save while mouse is active - this prevents interference with mouse interactions
if (isMouseActiveRef.current) {
console.log('⏸️ Deferring persistence - mouse is active')
pendingSaveRef.current = true
return
}
try {
const doc = handle.doc()
if (!doc || !doc.store) {
console.log("🔍 No document to save yet")
return
}
// Generate hash of current document state
const currentHash = generateDocHash(doc)
const lastHash = lastSentHashRef.current
// Skip save if document hasn't changed
if (currentHash === lastHash) {
console.log('⏭️ Skipping persistence - document unchanged (hash matches)')
return
}
// OPTIMIZED: Defer JSON.stringify to avoid blocking main thread
// Use requestIdleCallback to serialize when browser is idle
const storeKeys = Object.keys(doc.store).length
// Defer expensive serialization to avoid blocking
const serializedDoc = await new Promise<string>((resolve, reject) => {
const serialize = () => {
try {
// Direct JSON.stringify - browser optimizes this internally
// The key is doing it in an idle callback to not block interactions
const json = JSON.stringify(doc)
resolve(json)
} catch (error) {
reject(error)
}
}
// Use requestIdleCallback if available to serialize when browser is idle
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(serialize, { timeout: 200 })
} else {
// Fallback: use setTimeout to defer to next event loop tick
setTimeout(serialize, 0)
}
})
// CRITICAL: Always log saves to help debug persistence issues
const shapeCount = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
console.log(`💾 Persisting document to worker for R2 storage: ${storeKeys} records, ${shapeCount} shapes`)
// 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: serializedDoc,
})
if (!response.ok) {
throw new Error(`Failed to save to worker: ${response.statusText}`)
}
// Update last sent hash only after successful save
lastSentHashRef.current = currentHash
pendingSaveRef.current = false
// CRITICAL: Always log successful saves
const finalShapeCount = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
console.log(`✅ Successfully sent document state to worker for persistence (${finalShapeCount} shapes)`)
} catch (error) {
console.error('❌ Error saving document to worker:', error)
pendingSaveRef.current = false
}
}
// Store save function reference for mouse release handler
saveFunctionRef.current = saveDocumentToWorker
const scheduleSave = () => {
// Clear existing timeout
if (saveTimeout) clearTimeout(saveTimeout)
// CRITICAL: Check if mouse is active before scheduling save
if (isMouseActiveRef.current) {
console.log('⏸️ Deferring save scheduling - mouse is active')
pendingSaveRef.current = true
// Schedule a check for when mouse is released
const checkMouseState = () => {
if (!isMouseActiveRef.current && pendingSaveRef.current) {
pendingSaveRef.current = false
// Mouse is released, schedule the save now
requestAnimationFrame(() => {
saveTimeout = setTimeout(saveDocumentToWorker, 3000)
})
} else if (isMouseActiveRef.current) {
// Mouse still active, check again in 100ms
setTimeout(checkMouseState, 100)
}
}
setTimeout(checkMouseState, 100)
return
}
// CRITICAL: Use requestIdleCallback if available to defer saves until browser is idle
// This prevents saves from interrupting active interactions
const schedule = () => {
// Schedule save with a debounce (3 seconds) to batch rapid changes
saveTimeout = setTimeout(saveDocumentToWorker, 3000)
}
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(schedule, { timeout: 2000 })
} else {
requestAnimationFrame(schedule)
}
}
// Listen for changes to the Automerge document
const changeHandler = (payload: any) => {
const patchCount = payload.patches?.length || 0
if (!patchCount) {
// No patches, nothing to save
return
}
// CRITICAL: If mouse is active, defer all processing to avoid blocking mouse interactions
if (isMouseActiveRef.current) {
// Just mark that we have pending changes, process them when mouse is released
pendingSaveRef.current = true
return
}
// Process patches asynchronously to avoid blocking
requestAnimationFrame(() => {
// Double-check mouse state after animation frame
if (isMouseActiveRef.current) {
pendingSaveRef.current = true
return
}
// Filter out ephemeral record changes - these shouldn't trigger persistence
const ephemeralIdPatterns = [
'instance:',
'instance_page_state:',
'instance_presence:',
'camera:',
'pointer:'
]
// Quick check for ephemeral changes (lightweight)
const hasOnlyEphemeralChanges = payload.patches.every((p: any) => {
const id = p.path?.[1]
if (!id || typeof id !== 'string') return false
return ephemeralIdPatterns.some(pattern => id.startsWith(pattern))
})
// If all patches are for ephemeral records, skip persistence
if (hasOnlyEphemeralChanges) {
console.log('🚫 Skipping persistence - only ephemeral changes detected:', {
patchCount
})
return
}
// Check if patches contain shape changes (lightweight check)
const hasShapeChanges = payload.patches?.some((p: any) => {
const id = p.path?.[1]
return id && typeof id === 'string' && id.startsWith('shape:')
})
if (hasShapeChanges) {
// Check if ALL patches are only position updates (x/y) for pinned-to-view shapes
// These shouldn't trigger persistence since they're just keeping the shape in the same screen position
// NOTE: We defer doc access to avoid blocking, but do lightweight path checks
const allPositionUpdates = payload.patches.every((p: any) => {
const shapeId = p.path?.[1]
// If this is not a shape patch, it's not a position update
if (!shapeId || typeof shapeId !== 'string' || !shapeId.startsWith('shape:')) {
return false
}
// Check if this is a position update (x or y coordinate)
// Path format: ['store', 'shape:xxx', 'x'] or ['store', 'shape:xxx', 'y']
const pathLength = p.path?.length || 0
return pathLength === 3 && (p.path[2] === 'x' || p.path[2] === 'y')
})
// If all patches are position updates, check if they're for pinned shapes
// This requires doc access, so we defer it slightly
if (allPositionUpdates && payload.patches.length > 0) {
// Defer expensive doc access check
setTimeout(() => {
if (isMouseActiveRef.current) {
pendingSaveRef.current = true
return
}
const doc = handle.doc()
const allPinned = payload.patches.every((p: any) => {
const shapeId = p.path?.[1]
if (!shapeId || typeof shapeId !== 'string' || !shapeId.startsWith('shape:')) {
return false
}
if (doc?.store?.[shapeId]) {
const shape = doc.store[shapeId]
return shape?.props?.pinnedToView === true
}
return false
})
if (allPinned) {
console.log('🚫 Skipping persistence - only pinned-to-view position updates detected:', {
patchCount: payload.patches.length
})
return
}
// Not all pinned, schedule save
scheduleSave()
}, 0)
return
}
const shapePatches = payload.patches.filter((p: any) => {
const id = p.path?.[1]
return id && typeof id === 'string' && id.startsWith('shape:')
})
// CRITICAL: Always log shape changes to debug persistence
if (shapePatches.length > 0) {
console.log('🔍 Automerge document changed with shape patches:', {
patchCount: patchCount,
shapePatches: shapePatches.length
})
}
}
// Schedule save to worker for persistence (only for non-ephemeral changes)
scheduleSave()
})
}
handle.on('change', changeHandler)
// Don't save immediately on mount - only save when actual changes occur
// The initial document load from server is already persisted, so we don't need to re-persist it
return () => {
handle.off('change', changeHandler)
if (saveTimeout) clearTimeout(saveTimeout)
}
}, [handle, roomId, workerUrl, generateDocHash])
// 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
}
}

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

@ -0,0 +1,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;
}
}

View File

@ -0,0 +1,714 @@
import React, { useState, useEffect, useContext, useRef } from 'react'
import { useEditor } from 'tldraw'
import { createShapeId } from 'tldraw'
import { WORKER_URL, LOCAL_WORKER_URL } from '../constants/workerUrl'
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey } from '../lib/fathomApiKey'
import { AuthContext } from '../context/AuthContext'
interface FathomMeeting {
recording_id: number
title: string
meeting_title?: string
url: string
share_url?: string
created_at: string
scheduled_start_time?: string
scheduled_end_time?: string
recording_start_time?: string
recording_end_time?: string
transcript?: any[]
transcript_language?: string
default_summary?: {
template_name?: string
markdown_formatted?: string
}
action_items?: any[]
calendar_invitees?: Array<{
name: string
email: string
is_external: boolean
}>
recorded_by?: {
name: string
email: string
team?: string
}
call_id?: string | number
id?: string | number
}
interface FathomMeetingsPanelProps {
onClose?: () => void
onMeetingSelect?: (meeting: FathomMeeting, options: { summary: boolean; transcript: boolean; actionItems: boolean; video: boolean }, format: 'fathom' | 'note') => void
shapeMode?: boolean
}
export function FathomMeetingsPanel({ onClose, onMeetingSelect, shapeMode = false }: FathomMeetingsPanelProps) {
const editor = useEditor()
// Safely get auth context - may not be available during SVG export
const authContext = useContext(AuthContext)
const fallbackSession = {
username: undefined as string | undefined,
}
const session = authContext?.session || fallbackSession
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)
// Removed dropdown state - using buttons instead
const fetchMeetings = async (keyToUse?: string) => {
const key = keyToUse || apiKey
if (!key) {
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: {
'X-Api-Key': key,
'Content-Type': 'application/json'
}
})
} catch (error) {
console.log('Production worker failed, trying local worker...')
response = await fetch(`${LOCAL_WORKER_URL}/fathom/meetings`, {
headers: {
'X-Api-Key': key,
'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) {
saveFathomApiKey(apiKey, session.username)
setShowApiKeyInput(false)
fetchMeetings(apiKey)
}
}
// Track if we've already loaded meetings for the current user to prevent multiple API calls
const hasLoadedRef = useRef<string | undefined>(undefined)
const hasMountedRef = useRef(false)
useEffect(() => {
// Only run once on mount, don't re-fetch when session.username changes
if (hasMountedRef.current) {
return // Already loaded, don't refresh
}
hasMountedRef.current = true
// Always check user profile first for API key, then fallback to global storage
const username = session.username
const storedApiKey = getFathomApiKey(username)
if (storedApiKey) {
setApiKey(storedApiKey)
setShowApiKeyInput(false)
// Automatically fetch meetings when API key is available
// Only fetch once per user to prevent unnecessary API calls
if (hasLoadedRef.current !== username) {
hasLoadedRef.current = username
fetchMeetings(storedApiKey)
}
} else {
setShowApiKeyInput(true)
hasLoadedRef.current = undefined
}
}, []) // Empty dependency array - only run once on mount
// Handler for individual data type buttons - creates shapes directly
const handleDataButtonClick = async (meeting: FathomMeeting, dataType: 'summary' | 'transcript' | 'actionItems' | 'video') => {
// Log to verify the correct meeting is being used
console.log('🔵 handleDataButtonClick called with meeting:', {
recording_id: meeting.recording_id,
title: meeting.title,
dataType
})
if (!onMeetingSelect) {
// Fallback for non-browser mode
const options = {
summary: dataType === 'summary',
transcript: dataType === 'transcript',
actionItems: dataType === 'actionItems',
video: dataType === 'video',
}
await addMeetingToCanvas(meeting, options)
return
}
// Browser mode - use callback with specific data type
// IMPORTANT: Pass the meeting object directly to ensure each button uses its own meeting's data
const options = {
summary: dataType === 'summary',
transcript: dataType === 'transcript',
actionItems: dataType === 'actionItems',
video: dataType === 'video',
}
// Always use 'note' format for summary, transcript, and action items (same behavior)
// Video opens URL directly, so format doesn't matter for it
const format = 'note'
onMeetingSelect(meeting, options, format)
}
const formatMeetingDataAsMarkdown = (fullMeeting: any, meeting: FathomMeeting, options: { summary: boolean; transcript: boolean; actionItems: boolean; video: boolean }): string => {
const parts: string[] = []
// Title
parts.push(`# ${fullMeeting.title || meeting.meeting_title || meeting.title || 'Meeting'}\n`)
// Video link if selected
if (options.video && (fullMeeting.url || meeting.url)) {
parts.push(`**Video:** [Watch Recording](${fullMeeting.url || meeting.url})\n`)
}
// Summary if selected
if (options.summary && fullMeeting.default_summary?.markdown_formatted) {
parts.push(`## Summary\n\n${fullMeeting.default_summary.markdown_formatted}\n`)
}
// Action Items if selected
if (options.actionItems && fullMeeting.action_items && fullMeeting.action_items.length > 0) {
parts.push(`## Action Items\n\n`)
fullMeeting.action_items.forEach((item: any) => {
const description = item.description || item.text || ''
const assignee = item.assignee?.name || item.assignee || ''
const dueDate = item.due_date || ''
parts.push(`- [ ] ${description}`)
if (assignee) parts[parts.length - 1] += ` (@${assignee})`
if (dueDate) parts[parts.length - 1] += ` - Due: ${dueDate}`
parts[parts.length - 1] += '\n'
})
parts.push('\n')
}
// Transcript if selected
if (options.transcript && fullMeeting.transcript && fullMeeting.transcript.length > 0) {
parts.push(`## Transcript\n\n`)
fullMeeting.transcript.forEach((entry: any) => {
const speaker = entry.speaker?.display_name || 'Unknown'
const text = entry.text || ''
const timestamp = entry.timestamp || ''
if (timestamp) {
parts.push(`**${speaker}** (${timestamp}): ${text}\n\n`)
} else {
parts.push(`**${speaker}**: ${text}\n\n`)
}
})
}
return parts.join('')
}
const addMeetingToCanvas = async (meeting: FathomMeeting, options: { summary: boolean; transcript: boolean; actionItems: boolean; video: boolean }) => {
try {
// If video is selected, just open the Fathom URL directly
if (options.video) {
// Try multiple sources for the correct video URL
// The Fathom API may provide url, share_url, or we may need to construct from call_id or id
const callId = meeting.call_id ||
meeting.id ||
meeting.recording_id
// Check if URL fields contain valid meeting URLs (contain /calls/)
const isValidMeetingUrl = (url: string) => url && url.includes('/calls/')
// Prioritize valid meeting URLs, then construct from call ID
const videoUrl = (meeting.url && isValidMeetingUrl(meeting.url)) ? meeting.url :
(meeting.share_url && isValidMeetingUrl(meeting.share_url)) ? meeting.share_url :
(callId ? `https://fathom.video/calls/${callId}` : null)
if (videoUrl) {
console.log('Opening Fathom video URL:', videoUrl, 'for meeting:', { callId, recording_id: meeting.recording_id })
window.open(videoUrl, '_blank', 'noopener,noreferrer')
} else {
console.error('Could not determine Fathom video URL for meeting:', meeting)
}
return
}
// Only fetch transcript if transcript is selected
const includeTranscript = options.transcript
// Fetch full meeting details
let response
try {
response = await fetch(`${WORKER_URL}/fathom/meetings/${meeting.recording_id}${includeTranscript ? '?include_transcript=true' : ''}`, {
headers: {
'X-Api-Key': apiKey,
'Content-Type': 'application/json'
}
})
} catch (error) {
console.log('Production worker failed, trying local worker...')
response = await fetch(`${LOCAL_WORKER_URL}/fathom/meetings/${meeting.recording_id}${includeTranscript ? '?include_transcript=true' : ''}`, {
headers: {
'X-Api-Key': apiKey,
'Content-Type': 'application/json'
}
})
}
if (!response.ok) {
setError(`Failed to fetch meeting details: ${response.status}`)
return
}
const fullMeeting = await response.json() as any
// If onMeetingSelect callback is provided, use it (browser mode - creates separate shapes)
if (onMeetingSelect) {
// Default to 'note' format for text data
onMeetingSelect(meeting, options, 'note')
// Browser stays open, don't close
return
}
// Fallback: create shape directly (for non-browser mode, like modal)
// Default to note format
const markdownContent = formatMeetingDataAsMarkdown(fullMeeting, meeting, options)
const title = fullMeeting.title || meeting.meeting_title || meeting.title || 'Fathom Meeting'
const shapeId = createShapeId()
editor.createShape({
id: shapeId,
type: 'ObsNote',
x: 100,
y: 100,
props: {
w: 400,
h: 500,
color: 'black',
size: 'm',
font: 'sans',
textAlign: 'start',
scale: 1,
noteId: `fathom-${meeting.recording_id}`,
title: title,
content: markdownContent,
tags: ['fathom', 'meeting'],
showPreview: true,
backgroundColor: '#ffffff',
textColor: '#000000',
isEditing: false,
editingContent: '',
isModified: false,
originalContent: markdownContent,
pinnedToView: false,
}
})
// Only close if not in shape mode (browser stays open)
if (!shapeMode && onClose) {
onClose()
}
} catch (error) {
console.error('Error adding meeting to canvas:', error)
setError(`Failed to add meeting: ${(error as Error).message}`)
}
}
// Removed dropdown click-outside handler - no longer needed with button-based interface
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) => {
// Prevent clicks from interfering with shape selection or resetting data
if (!shapeMode) {
e.stopPropagation()
}
// In shape mode, allow normal interaction but don't reset data
}}
onMouseDown={(e) => {
// Prevent shape deselection when clicking inside the browser content
if (shapeMode) {
e.stopPropagation()
}
}}
>
{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(apiKey)}
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={() => {
// Remove API key from user-specific storage
removeFathomApiKey(session.username)
setApiKey('')
setMeetings([])
setShowApiKeyInput(true)
hasLoadedRef.current = undefined
}}
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.recording_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: {meeting.recording_start_time && meeting.recording_end_time
? formatDuration(Math.floor((new Date(meeting.recording_end_time).getTime() - new Date(meeting.recording_start_time).getTime()) / 1000))
: 'N/A'}</div>
</div>
{meeting.default_summary?.markdown_formatted && (
<div style={{
fontSize: '11px',
color: '#333',
marginBottom: '8px',
userSelect: 'text',
cursor: 'text'
}}>
<strong>Summary:</strong> {meeting.default_summary.markdown_formatted.substring(0, 100)}...
</div>
)}
</div>
<div style={{
display: 'flex',
flexDirection: 'row',
gap: '6px',
marginLeft: '10px',
alignItems: 'center',
flexWrap: 'wrap'
}}>
<button
onClick={() => handleDataButtonClick(meeting, 'summary')}
disabled={loading}
style={{
padding: '6px 12px',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer',
fontSize: '11px',
whiteSpace: 'nowrap',
opacity: loading ? 0.6 : 1
}}
title="Add Summary as Note"
>
📄 Summary
</button>
<button
onClick={() => handleDataButtonClick(meeting, 'transcript')}
disabled={loading}
style={{
padding: '6px 12px',
backgroundColor: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer',
fontSize: '11px',
whiteSpace: 'nowrap',
opacity: loading ? 0.6 : 1
}}
title="Add Transcript as Note"
>
📝 Transcript
</button>
<button
onClick={() => handleDataButtonClick(meeting, 'actionItems')}
disabled={loading}
style={{
padding: '6px 12px',
backgroundColor: '#1d4ed8',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer',
fontSize: '11px',
whiteSpace: 'nowrap',
opacity: loading ? 0.6 : 1
}}
title="Add Action Items as Note"
>
Actions
</button>
<button
onClick={() => handleDataButtonClick(meeting, 'video')}
disabled={loading}
style={{
padding: '6px 12px',
backgroundColor: '#1e40af',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer',
fontSize: '11px',
whiteSpace: 'nowrap',
opacity: loading ? 0.6 : 1
}}
title="Add Video as Embed"
>
🎥 Video
</button>
</div>
</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>
)
}

View File

@ -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>
)
}

View File

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

View File

@ -0,0 +1,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

View File

@ -0,0 +1,337 @@
import React, { useState, useEffect } from 'react'
export interface TmuxSession {
name: string
windows: number
created: string
attached: boolean
}
interface SessionBrowserProps {
onSelectSession: (sessionId: string) => void
onCreateSession: (sessionName: string) => void
onRefresh?: () => void
}
export const SessionBrowser: React.FC<SessionBrowserProps> = ({
onSelectSession,
onCreateSession,
onRefresh
}) => {
const [sessions, setSessions] = useState<TmuxSession[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [newSessionName, setNewSessionName] = useState('')
const [showCreateForm, setShowCreateForm] = useState(false)
useEffect(() => {
fetchSessions()
}, [])
const fetchSessions = async () => {
setIsLoading(true)
setError(null)
try {
// TODO: Replace with actual worker endpoint
const response = await fetch('/terminal/sessions')
if (!response.ok) {
throw new Error(`Failed to fetch sessions: ${response.statusText}`)
}
const data = await response.json() as { sessions?: TmuxSession[] }
setSessions(data.sessions || [])
} catch (err) {
console.error('Error fetching tmux sessions:', err)
setError(err instanceof Error ? err.message : 'Failed to fetch sessions')
// For development: show mock data
setSessions([
{ name: 'canvas-main', windows: 3, created: '2025-01-19T10:00:00Z', attached: true },
{ name: 'dev-session', windows: 1, created: '2025-01-19T09:30:00Z', attached: false },
])
} finally {
setIsLoading(false)
}
}
const handleAttach = (sessionName: string) => {
onSelectSession(sessionName)
}
const handleCreate = (e: React.FormEvent) => {
e.preventDefault()
if (!newSessionName.trim()) return
onCreateSession(newSessionName.trim())
setNewSessionName('')
setShowCreateForm(false)
}
const handleRefresh = () => {
fetchSessions()
onRefresh?.()
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 60) {
return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`
} else if (diffHours < 24) {
return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`
} else if (diffDays < 7) {
return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`
} else {
return date.toLocaleDateString()
}
}
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#1e1e1e',
color: '#d4d4d4',
padding: '16px',
overflow: 'auto',
pointerEvents: 'all',
touchAction: 'auto',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
}}
>
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: 600 }}>
tmux Sessions
</h3>
<button
onClick={handleRefresh}
style={{
background: 'transparent',
border: '1px solid #444',
borderRadius: '4px',
color: '#d4d4d4',
padding: '4px 12px',
cursor: 'pointer',
fontSize: '12px',
pointerEvents: 'all',
}}
onPointerDown={(e) => e.stopPropagation()}
>
🔄 Refresh
</button>
</div>
{isLoading && (
<div style={{ textAlign: 'center', padding: '32px', color: '#888' }}>
Loading sessions...
</div>
)}
{error && (
<div
style={{
backgroundColor: '#3a1f1f',
border: '1px solid #cd3131',
borderRadius: '4px',
padding: '12px',
marginBottom: '16px',
fontSize: '13px',
}}
>
<strong> Error:</strong> {error}
</div>
)}
{!isLoading && sessions.length === 0 && (
<div style={{ textAlign: 'center', padding: '32px', color: '#888' }}>
No tmux sessions found. Create a new one to get started.
</div>
)}
{!isLoading && sessions.length > 0 && (
<div style={{ flex: 1, overflow: 'auto', marginBottom: '16px' }}>
{sessions.map((session) => (
<div
key={session.name}
style={{
backgroundColor: '#2d2d2d',
border: '1px solid #444',
borderRadius: '6px',
padding: '12px',
marginBottom: '8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
transition: 'border-color 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#10b981'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#444'
}}
>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<span
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: session.attached ? '#10b981' : '#666',
display: 'inline-block',
}}
/>
<strong style={{ fontSize: '14px' }}>{session.name}</strong>
</div>
<div style={{ fontSize: '12px', color: '#888', marginLeft: '16px' }}>
{session.windows} window{session.windows !== 1 ? 's' : ''} Created {formatDate(session.created)}
</div>
</div>
<button
onClick={() => handleAttach(session.name)}
onPointerDown={(e) => e.stopPropagation()}
style={{
backgroundColor: '#10b981',
color: '#ffffff',
border: 'none',
borderRadius: '4px',
padding: '6px 16px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
pointerEvents: 'all',
transition: 'background-color 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#0ea472'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#10b981'
}}
>
Attach
</button>
</div>
))}
</div>
)}
<div style={{ borderTop: '1px solid #444', paddingTop: '16px' }}>
{!showCreateForm ? (
<button
onClick={() => setShowCreateForm(true)}
onPointerDown={(e) => e.stopPropagation()}
style={{
width: '100%',
backgroundColor: 'transparent',
border: '2px dashed #444',
borderRadius: '6px',
color: '#10b981',
padding: '12px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
pointerEvents: 'all',
transition: 'border-color 0.2s, background-color 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#10b981'
e.currentTarget.style.backgroundColor = 'rgba(16, 185, 129, 0.1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#444'
e.currentTarget.style.backgroundColor = 'transparent'
}}
>
+ Create New Session
</button>
) : (
<form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<input
type="text"
value={newSessionName}
onChange={(e) => setNewSessionName(e.target.value)}
placeholder="Enter session name..."
autoFocus
style={{
backgroundColor: '#2d2d2d',
border: '1px solid #444',
borderRadius: '4px',
color: '#d4d4d4',
padding: '8px 12px',
fontSize: '13px',
outline: 'none',
pointerEvents: 'all',
touchAction: 'manipulation',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = '#10b981'
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#444'
}}
onPointerDown={(e) => e.stopPropagation()}
/>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="submit"
onPointerDown={(e) => e.stopPropagation()}
style={{
flex: 1,
backgroundColor: '#10b981',
color: '#ffffff',
border: 'none',
borderRadius: '4px',
padding: '8px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
pointerEvents: 'all',
}}
>
Create
</button>
<button
type="button"
onClick={() => {
setShowCreateForm(false)
setNewSessionName('')
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
flex: 1,
backgroundColor: 'transparent',
color: '#d4d4d4',
border: '1px solid #444',
borderRadius: '4px',
padding: '8px',
cursor: 'pointer',
fontSize: '13px',
pointerEvents: 'all',
}}
>
Cancel
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,495 @@
import React, { useState, ReactNode, useEffect, useRef } 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
/** Whether the shape is pinned to view */
isPinnedToView?: boolean
/** Callback when pin button is clicked */
onPinToggle?: () => void
/** Tags to display at the bottom of the shape */
tags?: string[]
/** Callback when tags are updated */
onTagsChange?: (tags: string[]) => void
/** Whether tags can be edited */
tagsEditable?: boolean
}
/**
* 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,
isPinnedToView = false,
onPinToggle,
tags = [],
onTagsChange,
tagsEditable = true,
}) => {
const [isHoveringHeader, setIsHoveringHeader] = useState(false)
const [isEditingTags, setIsEditingTags] = useState(false)
const [editingTagInput, setEditingTagInput] = useState('')
const tagInputRef = useRef<HTMLInputElement>(null)
// Bring selected shape to front when it becomes selected
useEffect(() => {
if (editor && shapeId && isSelected) {
try {
// Bring the shape to the front by updating its index
// Note: sendToFront doesn't exist in this version of tldraw
const allShapes = editor.getCurrentPageShapes()
let highestIndex = 'a0'
for (const s of allShapes) {
if (s.index && typeof s.index === 'string' && s.index > highestIndex) {
highestIndex = s.index
}
}
const shape = editor.getShape(shapeId)
if (shape) {
const match = highestIndex.match(/^([a-z])(\d+)$/)
if (match) {
const letter = match[1]
const num = parseInt(match[2], 10)
const newIndex = num < 100 ? `${letter}${num + 1}` : `${String.fromCharCode(letter.charCodeAt(0) + 1)}1`
if (/^[a-z]\d+$/.test(newIndex)) {
editor.updateShape({ id: shapeId, type: shape.type, index: newIndex as any })
}
}
}
} catch (error) {
// Silently fail if shape doesn't exist or operation fails
// This prevents console spam if shape is deleted during selection
}
}
}, [editor, shapeId, isSelected])
// 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 pinButtonStyle: React.CSSProperties = {
...buttonBaseStyle,
backgroundColor: isPinnedToView
? (isSelected ? 'rgba(255,255,255,0.4)' : primaryColor)
: (isSelected ? 'rgba(255,255,255,0.2)' : `${primaryColor}20`),
color: isPinnedToView
? (isSelected ? 'white' : 'white')
: (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',
flex: 1,
}
const tagsContainerStyle: React.CSSProperties = {
padding: '8px 12px',
borderTop: '1px solid #e0e0e0',
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
alignItems: 'center',
minHeight: '32px',
backgroundColor: '#f8f9fa',
flexShrink: 0,
}
const tagStyle: React.CSSProperties = {
backgroundColor: '#007acc',
color: 'white',
padding: '2px 6px',
borderRadius: '12px',
fontSize: '10px',
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
cursor: tagsEditable ? 'pointer' : 'default',
}
const tagInputStyle: React.CSSProperties = {
border: '1px solid #007acc',
borderRadius: '12px',
padding: '2px 6px',
fontSize: '10px',
outline: 'none',
minWidth: '60px',
flex: 1,
}
const addTagButtonStyle: React.CSSProperties = {
backgroundColor: '#007acc',
color: 'white',
border: 'none',
borderRadius: '12px',
padding: '2px 8px',
fontSize: '10px',
fontWeight: '500',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
}
const handleTagClick = (tag: string) => {
if (tagsEditable && onTagsChange) {
// Remove tag on click
const newTags = tags.filter(t => t !== tag)
onTagsChange(newTags)
}
}
const handleAddTag = () => {
if (editingTagInput.trim() && onTagsChange) {
const newTag = editingTagInput.trim().replace('#', '')
if (newTag && !tags.includes(newTag) && !tags.includes(`#${newTag}`)) {
const tagToAdd = newTag.startsWith('#') ? newTag : newTag
onTagsChange([...tags, tagToAdd])
}
setEditingTagInput('')
setIsEditingTags(false)
}
}
const handleTagInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
e.stopPropagation()
handleAddTag()
} else if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
setIsEditingTags(false)
setEditingTagInput('')
} else if (e.key === 'Backspace' && editingTagInput === '' && tags.length > 0) {
// Remove last tag if backspace on empty input
e.stopPropagation()
if (onTagsChange) {
onTagsChange(tags.slice(0, -1))
}
}
}
useEffect(() => {
if (isEditingTags && tagInputRef.current) {
tagInputRef.current.focus()
}
}, [isEditingTags])
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) => {
// Don't select if clicking on a button - let the button handle the click
const target = e.target as HTMLElement
const isButton =
target.tagName === 'BUTTON' ||
target.closest('button') ||
target.closest('[role="button"]')
if (isButton) {
return
}
// 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}>
{onPinToggle && (
<button
style={pinButtonStyle}
onClick={(e) => handleButtonClick(e, onPinToggle)}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
title={isPinnedToView ? "Unpin from view" : "Pin to view"}
aria-label={isPinnedToView ? "Unpin from view" : "Pin to view"}
>
📌
</button>
)}
<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()}
onMouseDown={(e) => e.stopPropagation()}
title="Minimize"
aria-label="Minimize"
disabled={!onMinimize}
>
_
</button>
<button
style={closeButtonStyle}
onClick={(e) => handleButtonClick(e, onClose)}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
title="Close"
aria-label="Close"
>
×
</button>
</div>
</div>
{/* Content Area */}
{!isMinimized && (
<>
<div
style={contentStyle}
onPointerDown={handleContentPointerDown}
>
{children}
</div>
{/* Tags at the bottom */}
{(tags.length > 0 || (tagsEditable && isSelected)) && (
<div
style={tagsContainerStyle}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
if (tagsEditable && !isEditingTags && e.target === e.currentTarget) {
setIsEditingTags(true)
}
}}
>
{tags.slice(0, 5).map((tag, index) => (
<span
key={index}
style={tagStyle}
onClick={(e) => {
e.stopPropagation()
handleTagClick(tag)
}}
title={tagsEditable ? "Click to remove tag" : undefined}
>
{tag.replace('#', '')}
{tagsEditable && <span style={{ fontSize: '8px' }}>×</span>}
</span>
))}
{tags.length > 5 && (
<span style={tagStyle}>
+{tags.length - 5}
</span>
)}
{isEditingTags && (
<input
ref={tagInputRef}
type="text"
value={editingTagInput}
onChange={(e) => setEditingTagInput(e.target.value)}
onKeyDown={handleTagInputKeyDown}
onBlur={() => {
handleAddTag()
}}
style={tagInputStyle}
placeholder="Add tag..."
onPointerDown={(e) => e.stopPropagation()}
/>
)}
{!isEditingTags && tagsEditable && isSelected && tags.length < 10 && (
<button
style={addTagButtonStyle}
onClick={(e) => {
e.stopPropagation()
setIsEditingTags(true)
}}
onPointerDown={(e) => e.stopPropagation()}
title="Add tag"
>
+ Add
</button>
)}
</div>
)}
</>
)}
</div>
)
}

View File

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

View File

@ -0,0 +1,510 @@
import React, { useEffect, useRef, useState } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import '@xterm/xterm/css/xterm.css'
import { SessionBrowser, TmuxSession } from './SessionBrowser'
interface TerminalContentProps {
sessionId: string
collaborationMode: boolean
ownerId: string
fontFamily: string
fontSize: number
theme: "dark" | "light"
isMinimized: boolean
width: number
height: number
onSessionChange: (newSessionId: string) => void
onCollaborationToggle: () => void
}
export const TerminalContent: React.FC<TerminalContentProps> = ({
sessionId,
collaborationMode,
ownerId,
fontFamily,
fontSize,
theme,
isMinimized,
width,
height,
onSessionChange,
onCollaborationToggle
}) => {
const terminalRef = useRef<HTMLDivElement>(null)
const termRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const [isConnected, setIsConnected] = useState(false)
const [error, setError] = useState<string | null>(null)
const [reconnectAttempt, setReconnectAttempt] = useState(0)
const [showSessionBrowser, setShowSessionBrowser] = useState(!sessionId)
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Get current user ID (TODO: replace with actual auth)
const currentUserId = 'user-123' // Placeholder
const isOwner = ownerId === currentUserId || !ownerId
const canInput = isOwner || collaborationMode
// Theme colors
const themes = {
dark: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#ffffff',
cursorAccent: '#1e1e1e',
selection: '#264f78',
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#e5e5e5'
},
light: {
background: '#ffffff',
foreground: '#333333',
cursor: '#000000',
cursorAccent: '#ffffff',
selection: '#add6ff',
black: '#000000',
red: '#cd3131',
green: '#00bc00',
yellow: '#949800',
blue: '#0451a5',
magenta: '#bc05bc',
cyan: '#0598bc',
white: '#555555',
brightBlack: '#666666',
brightRed: '#cd3131',
brightGreen: '#14ce14',
brightYellow: '#b5ba00',
brightBlue: '#0451a5',
brightMagenta: '#bc05bc',
brightCyan: '#0598bc',
brightWhite: '#a5a5a5'
}
}
// Initialize terminal
useEffect(() => {
if (!terminalRef.current || isMinimized || showSessionBrowser) return
// Create terminal instance
const term = new Terminal({
theme: themes[theme],
fontFamily,
fontSize,
lineHeight: 1.4,
cursorBlink: true,
cursorStyle: 'block',
scrollback: 10000,
tabStopWidth: 4,
allowProposedApi: true
})
const fitAddon = new FitAddon()
const webLinksAddon = new WebLinksAddon()
term.loadAddon(fitAddon)
term.loadAddon(webLinksAddon)
term.open(terminalRef.current)
fitAddonRef.current = fitAddon
termRef.current = term
// Fit terminal to container
try {
fitAddon.fit()
} catch (err) {
console.error('Error fitting terminal:', err)
}
// Handle user input
term.onData((data) => {
if (!canInput) {
term.write('\r\n\x1b[33m[Terminal is read-only. Owner must enable collaboration mode.]\x1b[0m\r\n')
return
}
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'input',
data: data,
sessionId
}))
}
})
// Cleanup
return () => {
term.dispose()
termRef.current = null
fitAddonRef.current = null
}
}, [sessionId, isMinimized, showSessionBrowser, fontFamily, fontSize, theme, canInput])
// Connect to WebSocket
useEffect(() => {
if (!sessionId || showSessionBrowser || isMinimized) return
connectWebSocket()
return () => {
disconnectWebSocket()
}
}, [sessionId, showSessionBrowser, isMinimized])
// Handle resize
useEffect(() => {
if (!fitAddonRef.current || isMinimized || showSessionBrowser) return
const resizeTimeout = setTimeout(() => {
try {
fitAddonRef.current?.fit()
// Send resize event to backend
if (termRef.current && wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'resize',
cols: termRef.current.cols,
rows: termRef.current.rows,
sessionId
}))
}
} catch (err) {
console.error('Error resizing terminal:', err)
}
}, 100)
return () => clearTimeout(resizeTimeout)
}, [width, height, isMinimized, showSessionBrowser, sessionId])
const connectWebSocket = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) return
setError(null)
try {
// TODO: Replace with actual worker URL
const wsUrl = `wss://${window.location.host}/terminal/ws/${sessionId}`
const ws = new WebSocket(wsUrl)
wsRef.current = ws
ws.onopen = () => {
console.log('WebSocket connected')
setIsConnected(true)
setReconnectAttempt(0)
// Initialize session
ws.send(JSON.stringify({
type: 'init',
sessionId,
cols: termRef.current?.cols || 80,
rows: termRef.current?.rows || 24
}))
if (termRef.current) {
termRef.current.write('\r\n\x1b[32m[Connected to tmux session: ' + sessionId + ']\x1b[0m\r\n')
}
}
ws.onmessage = (event) => {
try {
if (event.data instanceof Blob) {
// Binary data
const reader = new FileReader()
reader.onload = () => {
const text = reader.result as string
termRef.current?.write(text)
}
reader.readAsText(event.data)
} else {
// Text data (could be JSON or terminal output)
try {
const msg = JSON.parse(event.data)
handleServerMessage(msg)
} catch {
// Plain text terminal output
termRef.current?.write(event.data)
}
}
} catch (err) {
console.error('Error processing message:', err)
}
}
ws.onerror = (event) => {
console.error('WebSocket error:', event)
setError('Connection error')
}
ws.onclose = () => {
console.log('WebSocket closed')
setIsConnected(false)
wsRef.current = null
if (termRef.current) {
termRef.current.write('\r\n\x1b[31m[Disconnected from terminal]\x1b[0m\r\n')
}
// Attempt reconnection
attemptReconnect()
}
} catch (err) {
console.error('Error connecting WebSocket:', err)
setError(err instanceof Error ? err.message : 'Failed to connect')
}
}
const disconnectWebSocket = () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
}
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
setIsConnected(false)
}
const attemptReconnect = () => {
if (reconnectAttempt >= 5) {
setError('Connection lost. Max reconnection attempts reached.')
return
}
const delay = Math.min(1000 * Math.pow(2, reconnectAttempt), 16000)
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempt + 1}/5)`)
reconnectTimeoutRef.current = setTimeout(() => {
setReconnectAttempt(prev => prev + 1)
connectWebSocket()
}, delay)
}
const handleServerMessage = (msg: any) => {
switch (msg.type) {
case 'output':
if (msg.data && termRef.current) {
const data = typeof msg.data === 'string'
? msg.data
: new Uint8Array(msg.data)
termRef.current.write(data)
}
break
case 'status':
if (msg.status === 'disconnected') {
setError('Session disconnected')
}
break
case 'error':
setError(msg.message || 'Unknown error')
if (termRef.current) {
termRef.current.write(`\r\n\x1b[31m[Error: ${msg.message}]\x1b[0m\r\n`)
}
break
default:
console.log('Unhandled message type:', msg.type)
}
}
const handleSelectSession = (newSessionId: string) => {
setShowSessionBrowser(false)
onSessionChange(newSessionId)
}
const handleCreateSession = async (sessionName: string) => {
try {
// TODO: Replace with actual worker endpoint
const response = await fetch('/terminal/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: sessionName })
})
if (!response.ok) {
throw new Error('Failed to create session')
}
const data = await response.json()
setShowSessionBrowser(false)
onSessionChange(sessionName)
} catch (err) {
console.error('Error creating session:', err)
setError(err instanceof Error ? err.message : 'Failed to create session')
}
}
const handleDetach = () => {
disconnectWebSocket()
setShowSessionBrowser(true)
onSessionChange('')
}
if (isMinimized) {
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: themes[theme].background,
color: themes[theme].foreground,
fontSize: '13px',
pointerEvents: 'all',
}}
>
Terminal minimized
</div>
)
}
if (showSessionBrowser) {
return (
<SessionBrowser
onSelectSession={handleSelectSession}
onCreateSession={handleCreateSession}
/>
)
}
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: themes[theme].background,
position: 'relative',
pointerEvents: 'all',
touchAction: 'auto',
}}
>
{/* Status bar */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '4px 8px',
backgroundColor: theme === 'dark' ? '#2d2d2d' : '#f0f0f0',
borderBottom: `1px solid ${theme === 'dark' ? '#444' : '#ddd'}`,
fontSize: '11px',
color: themes[theme].foreground,
}}
>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<span>
<span
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: isConnected ? '#10b981' : '#cd3131',
display: 'inline-block',
marginRight: '4px',
}}
/>
{sessionId}
</span>
{!canInput && (
<span
style={{
backgroundColor: theme === 'dark' ? '#3a3a1f' : '#fff3cd',
color: theme === 'dark' ? '#e5e510' : '#856404',
padding: '2px 6px',
borderRadius: '3px',
fontSize: '10px',
}}
>
🔒 Read-only
</span>
)}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
{isOwner && (
<button
onClick={onCollaborationToggle}
onPointerDown={(e) => e.stopPropagation()}
style={{
background: 'transparent',
border: 'none',
color: collaborationMode ? '#10b981' : '#666',
cursor: 'pointer',
fontSize: '10px',
padding: '2px 6px',
pointerEvents: 'all',
}}
title={collaborationMode ? 'Collaboration enabled' : 'Collaboration disabled'}
>
👥 {collaborationMode ? 'ON' : 'OFF'}
</button>
)}
<button
onClick={handleDetach}
onPointerDown={(e) => e.stopPropagation()}
style={{
background: 'transparent',
border: 'none',
color: '#666',
cursor: 'pointer',
fontSize: '10px',
padding: '2px 6px',
pointerEvents: 'all',
}}
title="Switch session"
>
Switch
</button>
</div>
</div>
{/* Error banner */}
{error && (
<div
style={{
backgroundColor: '#3a1f1f',
color: '#f14c4c',
padding: '8px 12px',
fontSize: '12px',
borderBottom: '1px solid #cd3131',
}}
>
{error}
</div>
)}
{/* Terminal container */}
<div
ref={terminalRef}
style={{
flex: 1,
overflow: 'hidden',
padding: '4px',
}}
/>
</div>
)
}

View File

@ -0,0 +1,279 @@
import React, { useState, useEffect } from 'react';
import { CryptoAuthService } from '../../lib/auth/cryptoAuthService';
import { useAuth } from '../../context/AuthContext';
import { useNotifications } from '../../context/NotificationContext';
import { checkBrowserSupport, isSecureContext } from '../../lib/utils/browser';
interface CryptIDProps {
onSuccess?: () => void;
onCancel?: () => void;
}
/**
* CryptID - WebCryptoAPI-based authentication component
*/
const CryptID: React.FC<CryptIDProps> = ({ 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 CryptID Account' : 'CryptID 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 CryptID 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 CryptID 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 CryptID;

View File

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

View File

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

View File

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

View File

@ -0,0 +1,56 @@
import React, { useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import { useNotifications } from '../../context/NotificationContext';
import CryptID from './CryptID';
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">
<CryptID
onSuccess={handleLoginSuccess}
onCancel={handleLoginCancel}
/>
</div>
</div>
)}
</>
);
};
export default LoginButton;

View File

@ -0,0 +1,164 @@
import React, { useState } from 'react';
import { useAuth } from '../../context/AuthContext';
interface ProfileProps {
onLogout?: () => void;
onOpenVaultBrowser?: () => void;
}
export const Profile: React.FC<ProfileProps> = ({ onLogout, onOpenVaultBrowser }) => {
const { session, updateSession, clearSession } = useAuth();
const [vaultPath, setVaultPath] = useState(session.obsidianVaultPath || '');
const [isEditingVault, setIsEditingVault] = 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 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>CryptID: {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>
<div className="profile-actions">
<button onClick={handleLogout} className="logout-button">
Sign Out
</button>
</div>
{!session.backupCreated && (
<div className="backup-reminder">
<p>Remember to back up your encryption keys to prevent data loss!</p>
</div>
)}
</div>
);
};

View File

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

155
src/config/quartzSync.ts Normal file
View File

@ -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)
)
}

View File

@ -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`)

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

@ -0,0 +1,209 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from 'react';
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;
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);
// 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;
});
}, []);
/**
* Initialize the authentication state
*/
const initialize = useCallback(async (): Promise<void> => {
setSessionState(prev => ({ ...prev, loading: true }));
try {
const { session: newSession } = await AuthService.initialize();
setSessionState(newSession);
// 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) {
setSessionState(result.session);
// 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) {
setSessionState(result.session);
// 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
});
}, []);
/**
* 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,
initialize,
login,
register,
logout
}), [session, setSession, clearSession, initialize, login, register, logout]);
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@ -0,0 +1,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
}

View File

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

View File

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

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

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

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

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

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

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

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

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

425
src/css/location.css Normal file
View File

@ -0,0 +1,425 @@
/* Location Sharing Components Styles */
/* Spinner animation */
.spinner {
width: 20px;
height: 20px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Location Capture */
.location-capture {
width: 100%;
}
.capture-header h2 {
margin-bottom: 0.5rem;
}
.capture-button {
display: flex;
align-items: center;
justify-content: center;
}
/* Location Map */
.location-map-wrapper {
width: 100%;
}
.location-map {
width: 100%;
min-height: 300px;
}
.map-info {
margin-top: 0.75rem;
}
.map-loading,
.map-error {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
}
/* Share Settings */
.share-settings {
width: 100%;
}
.settings-header {
margin-bottom: 1rem;
}
.setting-group {
margin-bottom: 1.5rem;
}
.precision-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.precision-option {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
}
.precision-option input[type="radio"] {
margin-top: 0.125rem;
cursor: pointer;
}
.privacy-notice {
padding: 1rem;
border-radius: 0.5rem;
background-color: rgba(var(--muted), 0.5);
}
/* Share Location Flow */
.share-location {
width: 100%;
max-width: 56rem;
margin: 0 auto;
padding: 1.5rem;
}
.progress-steps {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 2rem;
}
.step-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.step-number {
width: 2rem;
height: 2rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.step-connector {
height: 2px;
width: 3rem;
transition: all 0.2s;
}
.step-content {
width: 100%;
}
.settings-step {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.location-preview {
width: 100%;
}
.settings-actions {
display: flex;
gap: 0.75rem;
}
.share-step {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.share-success {
text-align: center;
margin-bottom: 1.5rem;
}
.share-link-box {
background-color: rgba(var(--muted), 0.5);
border: 1px solid rgba(var(--border), 1);
border-radius: 0.5rem;
padding: 1rem;
}
.share-link-box input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid rgba(var(--border), 1);
border-radius: 0.5rem;
background-color: rgba(var(--background), 1);
font-size: 0.875rem;
}
.share-details {
background-color: rgba(var(--muted), 0.5);
border-radius: 0.5rem;
padding: 1rem;
}
.detail-row {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
}
/* Location Viewer */
.location-viewer {
width: 100%;
max-width: 56rem;
margin: 0 auto;
padding: 1.5rem;
}
.viewer-header {
margin-bottom: 1.5rem;
}
.viewer-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.share-info {
background-color: rgba(var(--muted), 0.5);
border-radius: 0.5rem;
padding: 1rem;
}
.info-row {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.info-row:last-child {
margin-bottom: 0;
}
/* Location Dashboard */
.location-dashboard {
width: 100%;
max-width: 72rem;
margin: 0 auto;
padding: 1.5rem;
}
.dashboard-header {
margin-bottom: 2rem;
}
.dashboard-content {
width: 100%;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background-color: rgba(var(--muted), 0.5);
border: 1px solid rgba(var(--border), 1);
border-radius: 0.5rem;
padding: 1rem;
}
.stat-label {
font-size: 0.875rem;
color: rgba(var(--muted-foreground), 1);
margin-bottom: 0.25rem;
}
.stat-value {
font-size: 1.875rem;
font-weight: 700;
}
.shares-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.share-card {
background-color: rgba(var(--background), 1);
border-radius: 0.5rem;
border: 2px solid rgba(var(--border), 1);
transition: all 0.2s;
}
.share-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
}
.share-info {
flex: 1;
}
.share-meta {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
color: rgba(var(--muted-foreground), 1);
}
.share-actions {
display: flex;
gap: 0.5rem;
}
.share-card-body {
padding: 1rem;
padding-top: 0;
border-top: 1px solid rgba(var(--border), 1);
margin-top: 1rem;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
text-align: center;
}
/* Auth required messages */
.share-location-auth,
.location-dashboard-auth {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
}
/* Error messages */
.error-message {
background-color: rgba(var(--destructive), 0.1);
border: 1px solid rgba(var(--destructive), 0.2);
border-radius: 0.5rem;
padding: 1rem;
}
.permission-denied {
background-color: rgba(var(--destructive), 0.1);
border: 1px solid rgba(var(--destructive), 0.2);
border-radius: 0.5rem;
padding: 1rem;
margin-top: 1rem;
}
.current-location {
background-color: rgba(var(--muted), 0.5);
border-radius: 0.5rem;
padding: 1rem;
margin-top: 1rem;
}
.location-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.share-location,
.location-viewer,
.location-dashboard {
padding: 1rem;
}
.progress-steps {
flex-wrap: wrap;
}
.step-connector {
display: none;
}
.stats-grid {
grid-template-columns: 1fr;
}
.share-card-header {
flex-direction: column;
}
.share-actions {
width: 100%;
}
.share-actions button {
flex: 1;
}
}

1239
src/css/obsidian-browser.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,68 @@
/* Obsidian Toolbar Button Styles */
.obsidian-toolbar-button {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: #333;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.obsidian-toolbar-button:hover {
background: #f8f9fa;
border-color: #007acc;
color: #007acc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.obsidian-toolbar-button:active {
background: #e3f2fd;
border-color: #005a9e;
color: #005a9e;
transform: translateY(1px);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.obsidian-toolbar-button svg {
flex-shrink: 0;
width: 16px;
height: 16px;
}
.obsidian-toolbar-button span {
white-space: nowrap;
}
/* Integration with TLDraw toolbar */
.tlui-toolbar .obsidian-toolbar-button {
margin: 0 2px;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.obsidian-toolbar-button {
background: #2d2d2d;
border-color: #404040;
color: #e0e0e0;
}
.obsidian-toolbar-button:hover {
background: #3d3d3d;
border-color: #007acc;
color: #007acc;
}
.obsidian-toolbar-button:active {
background: #1a3a5c;
border-color: #005a9e;
color: #005a9e;
}
}

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

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

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

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

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

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

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

@ -0,0 +1,402 @@
/* Custom User Profile Styles */
.custom-user-profile {
position: absolute;
top: 8px;
right: 8px;
z-index: 1000;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(8px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: 8px 12px;
font-size: 14px;
font-weight: 500;
color: #333;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 8px;
user-select: none;
pointer-events: none;
transition: all 0.2s ease;
animation: profileSlideIn 0.3s ease-out;
}
.custom-user-profile .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
flex-shrink: 0;
animation: pulse 2s infinite;
}
.custom-user-profile .username {
font-weight: 600;
letter-spacing: 0.5px;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.custom-user-profile {
background: rgba(45, 45, 45, 0.9);
border-color: rgba(255, 255, 255, 0.1);
color: #e9ecef;
}
}
/* Animations */
@keyframes profileSlideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
/* Profile Container Styles */
.profile-container {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
max-width: 500px;
margin: 0 auto;
}
.profile-header h3 {
margin: 0 0 20px 0;
color: #333;
font-size: 24px;
font-weight: 600;
}
.profile-settings {
margin-bottom: 20px;
padding: 16px;
background: #f8f9fa;
border-radius: 6px;
border: 1px solid #e9ecef;
}
.profile-settings h4 {
margin: 0 0 16px 0;
color: #495057;
font-size: 18px;
font-weight: 600;
}
/* Current Vault Section */
.current-vault-section {
margin-bottom: 20px;
padding: 16px;
background: white;
border: 1px solid #e9ecef;
border-radius: 6px;
}
.vault-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.vault-name {
display: flex;
align-items: center;
gap: 8px;
}
.vault-label {
font-weight: 600;
color: #495057;
font-size: 14px;
}
.vault-name-text {
font-weight: 700;
color: #007acc;
font-size: 16px;
background: #e3f2fd;
padding: 4px 8px;
border-radius: 4px;
}
.vault-path-info {
font-size: 12px;
color: #6c757d;
font-family: monospace;
word-break: break-all;
background: #f8f9fa;
padding: 6px 8px;
border-radius: 4px;
border: 1px solid #e9ecef;
}
.no-vault-info {
text-align: center;
padding: 20px;
}
.no-vault-text {
color: #6c757d;
font-style: italic;
font-size: 14px;
}
/* Vault Actions Section */
.vault-actions-section {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.change-vault-button {
background: #007acc;
color: white;
border: none;
padding: 12px 20px;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 122, 204, 0.2);
}
.change-vault-button:hover {
background: #005a9e;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 122, 204, 0.3);
}
.disconnect-vault-button {
background: #dc3545;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.disconnect-vault-button:hover {
background: #c82333;
transform: translateY(-1px);
}
/* Advanced Settings */
.advanced-vault-settings {
margin-top: 16px;
border: 1px solid #e9ecef;
border-radius: 6px;
background: #f8f9fa;
}
.advanced-vault-settings summary {
padding: 12px 16px;
cursor: pointer;
font-weight: 500;
color: #495057;
background: #e9ecef;
border-radius: 6px 6px 0 0;
user-select: none;
}
.advanced-vault-settings summary:hover {
background: #dee2e6;
}
.advanced-vault-settings[open] summary {
border-radius: 6px 6px 0 0;
}
.advanced-vault-settings .vault-settings {
padding: 16px;
background: white;
border-radius: 0 0 6px 6px;
}
.vault-settings {
display: flex;
flex-direction: column;
gap: 12px;
}
.vault-edit-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.vault-path-input {
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
width: 100%;
box-sizing: border-box;
}
.vault-path-input:focus {
outline: none;
border-color: #007acc;
box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2);
}
.vault-edit-actions {
display: flex;
gap: 8px;
}
.vault-display {
display: flex;
flex-direction: column;
gap: 8px;
}
.vault-path-display {
min-height: 32px;
display: flex;
align-items: center;
}
.vault-path-text {
color: #495057;
font-size: 14px;
word-break: break-all;
background: white;
padding: 6px 8px;
border-radius: 4px;
border: 1px solid #e9ecef;
flex: 1;
}
.no-vault-text {
color: #6c757d;
font-style: italic;
font-size: 14px;
}
.vault-actions {
display: flex;
gap: 8px;
}
.edit-button, .save-button, .cancel-button, .clear-button {
padding: 6px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
background: white;
color: #495057;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.edit-button:hover, .save-button:hover {
background: #007acc;
color: white;
border-color: #007acc;
}
.save-button {
background: #28a745;
color: white;
border-color: #28a745;
}
.save-button:hover {
background: #218838;
border-color: #218838;
}
.cancel-button {
background: #6c757d;
color: white;
border-color: #6c757d;
}
.cancel-button:hover {
background: #5a6268;
border-color: #5a6268;
}
.clear-button {
background: #dc3545;
color: white;
border-color: #dc3545;
}
.clear-button:hover {
background: #c82333;
border-color: #c82333;
}
.profile-actions {
margin-bottom: 16px;
}
.logout-button {
background: #dc3545;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease;
}
.logout-button:hover {
background: #c82333;
}
.backup-reminder {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 4px;
padding: 12px;
color: #856404;
font-size: 14px;
}
.backup-reminder p {
margin: 0;
}
/* Responsive design */
@media (max-width: 768px) {
.custom-user-profile {
top: 4px;
right: 4px;
padding: 6px 10px;
font-size: 12px;
}
.profile-container {
padding: 16px;
margin: 0 16px;
}
.vault-edit-actions, .vault-actions {
flex-direction: column;
}
}

814
src/default_gestures.ts Normal file
View File

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

322
src/gestures.ts Normal file
View File

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

View File

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

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