Compare commits

...

215 Commits

Author SHA1 Message Date
Jeff Emmett 2759b1c65b perf: improve loading times with better code splitting
- Improve Vite chunk splitting (Board.js 7.3MB → 5.6MB, 23% smaller)
- Add separate chunks for codemirror, onnx, daily-video, sanitizers
- Enable gzip for wasm and octet-stream in nginx
- Add dns-prefetch and preconnect hints for worker URLs
- Increase gzip compression level to 6

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 23:36:57 -05:00
Jeff Emmett 49cd1928dc feat: update Docker config for VITE_WORKER_ENV support
- Dockerfile now uses VITE_WORKER_ENV instead of hardcoded worker URL
- docker-compose.yml uses VITE_WORKER_ENV=production
- docker-compose.dev.yml uses VITE_WORKER_ENV=staging (points to dev worker)
- Staging site will use jeffemmett-canvas-dev.jeffemmett.workers.dev

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 22:48:27 -05:00
Jeff Emmett 5504bbd737 fix: correct dev worker URL after deployment
- jeffemmett-canvas-dev.jeffemmett.workers.dev is the actual deployed URL

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 22:45:19 -05:00
Jeff Emmett 048ad1dec4 feat: add staging environment for Netcup deployment
- Update workerUrl.ts to support staging/dev environments pointing to Cloudflare dev worker
- 'staging' and 'dev' now use jeffemmett-canvas-automerge-dev worker
- 'local' still uses localhost:5172 for local development
- Set VITE_WORKER_ENV=staging when building for Netcup staging

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 22:32:19 -05:00
Jeff Emmett c0058e3f2f fix: address multiple runtime issues
- Register missing shape types in Automerge schema (CalendarEvent, HolonBrowser, PrivateWorkspace, GoogleItem, WorkflowBlock)
- Improve connections API error handling to detect HTML responses gracefully
- Clean up Vite config debug logs
- Add static PWA manifest and link to index.html for proper manifest serving

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 21:06:20 -05:00
Jeff Emmett d92077e4f7 fix: remove orphaned debug statements causing build errors
- Remove incomplete console.log cleanup artifacts across 17 files
- Fix orphaned object literals left from previous debug removal
- Prefix unused callback parameters with underscores
- Update Drawfast shape to side-by-side INPUT/OUTPUT layout (900x500)
- Remove unused overlay toggle from Drawfast controls

Files fixed: AutomergeToTLStore, TLStoreToAutomerge, useAutomergeSyncRepo,
FathomMeetingsPanel, NetworkGraphPanel, sessionPersistence, quartzSync,
testClientConfig, useCollaboration, Board, DrawfastShapeUtil,
FathomMeetingsBrowserShapeUtil, MapShapeUtil, FathomMeetingsTool,
HolonTool, overrides, githubSetupValidator

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 19:38:27 -05:00
Jeff Emmett c6b53aebdb chore: remove verbose console.log debug statements
Cleaned up ~280 console.log statements across 64 files:
- Board.tsx: removed permission, auth, and shape visibility debug logs
- useWhisperTranscriptionSimple.ts: removed audio processing debug logs
- Automerge files: removed sync and patch debug logs
- Shape utilities: removed component lifecycle debug logs
- Lib files: removed API and config debug logs

Kept console.error and console.warn for actual error conditions.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 16:32:58 -05:00
Jeff Emmett 802e61abfe fix: add crossOrigin to video element to prevent tainted canvas errors
Videos from fal.media were causing "Tainted canvases may not be exported"
errors when tldraw tried to capture screenshots/exports. Adding crossOrigin="anonymous"
allows the browser to request the video with CORS headers.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 16:29:11 -05:00
Jeff Emmett d0bbc651ea feat: auto-configure FAL API key for Drawfast tool
- Updated LiveImageProvider to use getFalConfig() from clientConfig
- Drawfast now automatically uses the default FAL API key
- Users no longer need to manually enter API key to use the tool

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:07:57 -05:00
Jeff Emmett bcbea3bf76 feat: disable Holon functionality via HOLON_ENABLED flag
- Added HOLON_ENABLED feature flag (set to false) to completely disable Holon functionality
- HoloSphereService methods now return early with default values when disabled
- Removed all console.log/error output when Holon is disabled
- HolonShapeUtil shows "Feature Disabled" message when flag is false
- HolonBrowser shows disabled message instead of attempting connections
- Code preserved for future Nostr integration re-enablement

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 10:54:13 -05:00
Jeff Emmett 7f8d78036a fix: prevent stale cache issues with proper no-cache headers
- Add no-cache headers for index.html, sw.js, registerSW.js, manifest.webmanifest
- Add skipWaiting and clientsClaim to workbox config for immediate SW updates
- This ensures new deployments are picked up immediately without manual cache clearing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 10:43:25 -05:00
Jeff Emmett c6449a736d chore: clean up verbose console logs in AuthContext and VideoChatShapeUtil
Removed debug console.log statements while keeping console.error for
actual error conditions. This reduces console noise during normal
operation.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 10:42:14 -05:00
Jeff Emmett d21a674197 fix: register Calendar and Drawfast shapes in automerge store
Added missing Calendar and Drawfast shapes to the automerge store
schema registration to fix ValidationError when using these tools.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 10:36:51 -05:00
Jeff Emmett 84cb5d7bc8 chore: disable Multmux, Holon, and MycroZineGenerator tools
Temporarily hiding these tools from context menu and toolbar
until they are in a better working state.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:17:50 -05:00
Jeff Emmett d15130a337 feat: enable all tools in context menu and toolbar for dev testing
Enabled:
- Drawfast
- Holon
- Multmux/Terminal
- MycroZineGenerator

All tools now available in both the right-click context menu and
the top toolbar for testing on the dev/staging branch.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 01:31:12 -05:00
Jeff Emmett 35f9a1ad4f feat: switch VideoGen from RunPod to fal.ai
- Add fal.ai configuration to clientConfig.ts with default API key
- Update VideoGenShapeUtil to use fal.ai WAN 2.1 endpoints
- I2V mode uses fal-ai/wan-i2v, T2V mode uses fal-ai/wan-t2v
- Much faster startup time (no cold start) vs RunPod
- Processing time reduced from 2-6 min to 30-90 seconds

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 20:31:04 -05:00
Jeff Emmett d84e982ff0 feat: re-enable VideoGen tool in toolbar and context menu
Re-enabled the video generation tool for testing with the new fal.ai
MCP server backend. The tool was previously hidden while being developed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 20:22:20 -05:00
Jeff Emmett 035fa6f936 feat: add Flowy-like workflow builder system
Implements a visual workflow builder with:
- WorkflowBlockShapeUtil: Visual blocks with typed input/output ports
- WorkflowBlockTool: Click-to-place tool for adding blocks
- Block registry with 20+ blocks (triggers, actions, conditions, transformers, AI, outputs)
- Port validation and type compatibility checking
- WorkflowPropagator for real-time data flow between connected blocks
- Workflow executor for manual execution with topological ordering
- WorkflowPalette UI sidebar with searchable block categories
- JSON serialization for workflow export/import
- Workflow templates (API request, LLM chain, conditional)

Blocks are accessible via "Workflow Blocks" button in toolbar dropdown.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 15:45:27 -05:00
Jeff Emmett 6bfdf8c45d fix: use BaseBoxShapeTool for CalendarTool
The custom StateNode click handler wasn't working properly.
Switched to BaseBoxShapeTool like MapTool for reliable click-to-place.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 12:00:17 -05:00
Jeff Emmett 01e7cb2310 chore: remove broken workflow files temporarily
The Flowy workflow files have TypeScript errors that prevent builds.
Removing them entirely until they can be properly fixed and tested.

Files removed:
- src/components/workflow/
- src/css/workflow.css
- src/lib/workflow/
- src/propagators/WorkflowPropagator.ts
- src/shapes/WorkflowBlockShapeUtil.tsx
- src/tools/WorkflowBlockTool.ts

The commented-out imports in Board.tsx and CustomToolbar.tsx remain
as documentation of what needs to be re-added.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:33:50 -05:00
Jeff Emmett afd10a46f6 fix: disable Flowy workflow feature temporarily
Workflow Builder (Flowy-style visual workflow blocks) has TypeScript errors.
Temporarily disabling to allow Calendar feature to deploy.

Features disabled:
- WorkflowBlockShape (visual workflow blocks)
- WorkflowBlockTool (click-to-place workflow blocks)
- WorkflowPropagator (real-time data flow between blocks)
- WorkflowPalette (drag-and-drop block palette)

TODO: Fix workflow TypeScript errors and re-enable when ready to test.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:23:32 -05:00
Jeff Emmett 0f7ad6e3a1 fix: resolve TypeScript build errors for calendar and workflow
- CalendarEventShapeUtil: Fix destructuring (w,h are in props, not shape)
- CalendarPanel: Prefix unused variables with underscore
- YearViewPanel: Prefix unused variables with underscore
- Add missing workflow files (WorkflowPropagator, WorkflowBlockShape, etc.)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:20:15 -05:00
Jeff Emmett 6a6a140964 feat: add calendar tool to context menu and keyboard shortcuts
- Add Calendar to Create Tool submenu in context menu
- Add Calendar to keyboard shortcuts dialog

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:09:09 -05:00
Jeff Emmett 3b4c4988ac feat: add unified calendar tool with switchable views
- Add CalendarShapeUtil with view tabs (Browser/Widget/Year)
- Add CalendarTool for placing calendar on canvas
- Add CalendarEventShapeUtil for spawning event cards
- Add CalendarPanel component with month/week views
- Add YearViewPanel component with 12-month grid
- Add useCalendarEvents hook for fetching encrypted calendar data
- Single keyboard shortcut (Ctrl+Alt+K) with in-shape view switching
- Auto-resize when switching between views

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:02:13 -05:00
Jeff Emmett 8124a93766 fix: resolve content area height issue in StandardizedToolWrapper
- Remove conflicting height calc in contentStyle (was conflicting with flex:1)
- Use minHeight:0 to allow proper flex shrinking
- Add debug logging for pin toggle to diagnose pin button issues

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 22:18:11 -05:00
Jeff Emmett 822430ba4e chore: temporarily disable MycroZine generator for debugging
Commented out MycroZine generator from toolbar and context menu until
further debugging is completed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 22:11:13 -05:00
Jeff Emmett b42b1d3887 fix: improve onboarding tour first step to show full canvas
- Add noSpotlight option for steps that dim the canvas without a cutout
- Add center placement for viewport-centered tooltips
- Update first step to welcome users to the full canvas space
- Replace arbitrary square highlight with uniform overlay

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 22:09:03 -05:00
Jeff Emmett 0d663887b9 Update Open Graph metadata with research focus
- Add og-image.jpg (1200x630) for link previews
- Update description to reflect current research areas
- Fix typo in og:description ("doesn't" -> proper description)
- Topics: mycoeconomics, token engineering, psilo-cybernetics,
  zero-knowledge, local-first, institutional neuroplasticity

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 22:09:55 +01:00
Jeff Emmett 921092d29f feat: upgrade MycroZine generator to use full standalone API
- Use /api/outline for AI-generated 8-page outlines via Gemini
- Use /api/generate-page for individual page image generation
- Use /api/regenerate-page for page regeneration with feedback
- Use /api/print-layout for 300 DPI print-ready layout generation
- Remove legacy local generation functions
- Add proper error handling and API response parsing
- Include folding instructions in completion message

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 08:58:22 -05:00
Jeff Emmett cf07ab47e5 fix: improve admin request button error handling
- Added adminRequestError state to track request failures
- Parse and display server error messages to user
- Show red error button with retry option on failure
- Display error message below button explaining what went wrong

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 00:55:46 -05:00
Jeff Emmett e28ec80984 fix: make share and settings dropdowns opaque
- Use explicit background colors instead of CSS variables
- Add dark mode detection to ShareBoardButton
- Prevents see-through dropdowns

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 00:01:59 -05:00
Jeff Emmett c5aedb7756 fix: register MycroZineGenerator shape with automerge schema
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 23:59:21 -05:00
Jeff Emmett aafe41b23d test: trigger webhook deploy 2025-12-19 23:49:44 -05:00
Jeff Emmett c341fb59c5 feat: add Show Tutorial button, temporarily disable NetworkGraphPanel
- Add "Show Tutorial" button to mobile menu to trigger onboarding tour
- Comment out NetworkGraphPanel for main branch (code preserved)
- Add class name to CryptID dropdown for tour targeting

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 18:57:42 -05:00
Jeff Emmett 951eebca03 feat: add onboarding tooltip tour for new users
- Create 6-step spotlight tour covering key features:
  - Local-first storage
  - Encrypted identity (CryptID)
  - Share & collaborate
  - Creative toolkit
  - Mycelial Intelligence
  - Keyboard shortcuts

- Features:
  - Auto-starts for first-time users (1.5s delay)
  - Spotlight effect with darkened backdrop
  - Keyboard navigation (Escape, arrows, Enter)
  - "Show Tutorial" button in settings (desktop + mobile)
  - Dark mode support
  - Progress dots indicator

- New files: src/ui/OnboardingTour/{index,OnboardingTour,TourTooltip,tourSteps}.ts(x)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 18:10:23 -05:00
Jeff Emmett d333330cad feat: mobile UI improvements + staging deployment setup
- Remove anonymous viewer popup (anonymous users can now edit)
- Mobile menu consolidation: gear icon with all menus combined
- Connection status notifications below MI bar (Offline use, Reconnecting, Live)
- Network graph panel starts collapsed on mobile
- MI bar positioned at top on mobile

Deployment:
- Add docker-compose.dev.yml for staging.jeffemmett.com (dev branch)
- Update production docker-compose.yml to remove staging route

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 15:14:05 -05:00
Jeff Emmett c76f453042 fix: resolve Three.js Text component error and modal close issues
- Downgrade three.js from 0.182 to 0.168 to fix customDepthMaterial
  getter-only property breaking change (drei issue #2403)
- Add stable useCallback for modal close handler to prevent
  reference instability
- Improve ESC key handler with ref pattern and capture phase
  to ensure reliable modal closing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 15:08:55 -05:00
Jeff Emmett 6d3716a059 docs: update MycroZine task notes with RunPod proxy fix
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 23:21:24 -05:00
Jeff Emmett 7f2a186726 feat: route MycroZine image generation through RunPod proxy
- Updated generatePageImage to use zine.jeffemmett.com API
- Removed direct Gemini API calls (were geo-blocked in EU)
- Now uses RunPod US-based proxy for reliable image generation
- Fixed TypeScript types for API responses

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 23:18:13 -05:00
Jeff Emmett 19ff896594 feat: Gemini image generation for MycroZine + Tailscale dev support
MycroZine Generator:
- Implement actual Gemini image generation (replaces placeholders)
- Use Nano Banana Pro (gemini-2.0-flash-exp-image-generation) as primary
- Fallback to Gemini 2.0 Flash experimental
- Graceful degradation to placeholder if no API key

Client Config:
- Add geminiApiKey to ClientConfig interface
- Add isGeminiConfigured() and getGeminiConfig() functions
- Support user-specific API keys from localStorage

Local Development:
- Fix CORS to allow Tailscale IPs (100.x) and all private ranges
- Update cryptidEmailService to use same host for worker URL on local IPs
- Supports localhost, LAN (192.168.x, 10.x, 172.16-31.x), and Tailscale

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 21:35:20 -05:00
Jeff Emmett 46cc0485a2 feat: add PWA support for offline cold load
- Add vite-plugin-pwa with Workbox caching strategy
- Cache all static assets (JS, CSS, HTML, fonts, WASM)
- Enable service worker in dev mode for testing
- Add PWA manifest with app name and icons
- Add SVG icons for PWA (192x192 and 512x512)
- Increase cache limit to 10MB for large chunks (Board ~8MB)
- Add runtime caching for API responses and Google Fonts

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 21:34:05 -05:00
Jeff Emmett 03dde7cb16 feat: 3D network graph with trust clustering and broadcast mode
3D Visualization (NetworkGraph3D):
- Three.js force-directed graph with React Three Fiber
- Trust-level shells: trusted (inner), connected (middle), unconnected (outer)
- Node sizing proportional to decision power (incoming connections)
- Animated particle flows along edges showing delegation direction
- Zoom to user with smooth camera animation
- Orbit controls for 3D navigation (drag rotate, scroll zoom)

Broadcast Mode:
- "View as User" button syncs camera to selected user's view
- Visual indicator at top: "Viewing as [User] - ESC to exit"
- ESC or X key to stop following
- URL deep linking with ?followId parameter

UI Improvements:
- Panel now stacks directly above tldraw minimap
- Matched width (200px) with minimap for alignment
- Fixed D3 simulation stability (was reinitializing every render)
- 3-state display: minimized icon, normal panel, maximized 3D modal

Dependencies:
- three@^0.182.0
- @react-three/fiber@8.17.10 (React 18 compatible)
- @react-three/drei@9.114.3

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 21:06:50 -05:00
Jeff Emmett 5f8fa3d23a Create task task-057 2025-12-18 20:10:40 -05:00
Jeff Emmett 295fb2d3b4 Update task task-055 2025-12-18 18:24:08 -05:00
Jeff Emmett 28afe55c2a feat: smart backup system - skip unchanged boards
Instead of backing up every board daily (wasteful), we now:
1. Compute SHA-256 content hash for each board
2. Compare against last backed-up hash stored in R2
3. Only backup if content actually changed

Benefits:
- Reduces backup storage by 80-90%
- Enables extending retention beyond 90 days (less storage pressure)
- Each backup represents a real change, not duplicate snapshots
- Hash stored in `hashes/{room.key}.hash` for fast comparison

The cron still runs daily at midnight UTC, but now only boards
with actual changes get new backup entries.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 17:10:10 -05:00
Jeff Emmett 06d399cfbe feat: add worker unit tests for board permissions
Comprehensive test coverage for the board permissions system:
- handleGetPermission (authenticated/unauthenticated users)
- handleListPermissions (admin filtering)
- handleGrantPermission (editor assignment)
- handleRevokePermission (editor removal)
- handleUpdateBoard (protected status, global access)
- handleCreateAccessToken (security validation)
- handleListAccessTokens (admin-only access)
- handleRevokeAccessToken (token deletion)
- handleGetGlobalAdminStatus (admin checks)
- handleGetBoardInfo (board metadata)
- handleListEditors (editor listing)

Tests cover key security scenarios:
- Anonymous users get edit on new boards (permission model)
- Protected boards require authentication
- Access tokens cannot grant admin permissions
- View permission returned when database unavailable (secure default)

30 tests total, all passing.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 02:58:22 -05:00
Jeff Emmett 6f756c3292 feat: add worker unit tests for CryptID auth handlers
- Create 25 unit tests for CryptID authentication handlers
- Add vitest.worker.config.ts for worker test environment
- Update CI workflow to run worker tests
- Test coverage for:
  - handleCheckUsername (validation, normalization)
  - handleLinkEmail (validation, database errors)
  - handleVerifyEmail (token validation)
  - handleRequestDeviceLink (validation, 404 handling)
  - handleLinkDevice (token validation)
  - handleLookup (publicKey validation)
  - handleGetDevices (auth validation)
  - handleRevokeDevice (auth and validation)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 02:46:28 -05:00
Jeff Emmett 4b0049bd8f feat: add comprehensive test suite for CRDT, offline storage, and auth
- Add Vitest for unit tests with jsdom environment
- Add Playwright for E2E browser testing
- Create 27 unit tests for WebCrypto and IndexedDB
- Create 27 E2E tests covering:
  - Real-time collaboration (CRDT sync)
  - Offline storage and cold reload
  - CryptID authentication flows
- Add CI/CD workflow with coverage gates
- Configure test scripts in package.json

Test Results:
- Unit tests: 27 passed
- E2E tests: 26 passed, 1 flaky

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 02:42:01 -05:00
Jeff Emmett a23f420f94 Update task task-056 2025-12-18 02:26:01 -05:00
Jeff Emmett 6607d864b5 Create task task-056 2025-12-18 02:25:49 -05:00
Jeff Emmett b3bb17d2be fix: properly reset pin state to prevent shape jumping on re-pin
When pinning a shape again after unpinning, leftover state from the
previous session was causing the shape to jump/resize unexpectedly.

Changes:
- Add clearPinState() helper to reset all refs and cancel animations
- Add cleanShapeMeta() helper to remove all pin-related meta properties
- Clear all state immediately when pinning starts (before setting new state)
- Clear refs immediately when unpinning (not in setTimeout)
- Remove pinnedAtZoom from meta cleanup (legacy from CSS scaling)
- Don't call updatePinnedPosition() on pin start - shape is already
  at correct position, only need to listen for future camera changes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 19:57:21 -05:00
Jeff Emmett 865e646a1f fix: use store.listen for zero-lag pinned shape updates
Replace tick event with store.listen to react synchronously when the
camera record changes. This eliminates the one-frame delay that was
causing the shape and its indicator to lag behind camera movements.

Changes:
- Use editor.store.listen instead of editor.on('tick')
- Filter for camera record changes specifically
- Remove position threshold for maximum responsiveness
- Remove unused pinnedAtZoom since CSS scaling was removed

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 19:43:15 -05:00
Jeff Emmett 68298a7dd7 fix: remove CSS transform scaling from pinned shapes
Pinned shapes should only stay fixed in screen position, not fixed in
visual size. The CSS transform: scale() was causing shapes to appear
differently sized when pinned.

Now pinned shapes:
- Stay at a fixed screen position (don't move when panning)
- Scale normally with zoom (get bigger/smaller like other shapes)
- Don't change appearance when pin is toggled

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 12:11:45 -05:00
Jeff Emmett 11e39f0179 fix: use tldraw tick event for synchronous pinned shape updates
Replace requestAnimationFrame polling with tldraw's 'tick' event which
fires synchronously with the render cycle. This ensures the pinned shape
position is updated BEFORE rendering, eliminating the visual lag where
the shape appeared to "chase" the camera during zooming.

Changes:
- Use editor.on('tick') instead of requestAnimationFrame polling
- Remove throttling (no longer needed with tick event)
- Reduce position tolerance from 0.5 to 0.01 for more precise tracking
- Simplify code by removing unnecessary camera tracking refs

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:12:08 -05:00
Jeff Emmett a5e097f786 fix: prevent ValidationError by not setting undefined values in shape.meta
When unpinning a shape, the previous code set pinnedAtZoom, originalX, and
originalY to undefined in shape.meta. This caused a ValidationError because
tldraw requires JSON serializable values (undefined is not valid JSON).

Fixed by using object destructuring to exclude these properties from meta
instead of setting them to undefined.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:04:55 -05:00
Jeff Emmett 125ebacc00 fix: improve pinned shape zoom behavior - maintain constant visual size
- Pinned shapes now stay exactly the same size visually during zoom
- Uses CSS transform scale instead of changing w/h props
- Content inside shapes renders identically at all zoom levels
- Stores pinnedAtZoom in shape.meta for reference
- Returns to original position smoothly when unpinned
- Removed size-changing logic that was causing content reflow issues

The transform approach ensures text, UI elements, and all content
inside pinned shapes remain pixel-perfect regardless of canvas zoom.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:00:29 -05:00
Jeff Emmett 156dc4562c refactor: reorder context menu and remove Collections
- Move "Create Tool" to top of context menu
- Move "Shortcut to Frames" to second position
- Remove "Collections" submenu (functionality still available via keyboard shortcuts)
- Cleaner menu structure prioritizing creation tools

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 19:49:35 -05:00
Jeff Emmett 8d333510d5 feat: replace MycrozineTemplate with MycroZineGenerator in toolbar and context menu
- Updated CustomToolbar.tsx to use MycroZineGenerator tool
- Updated CustomContextMenu.tsx to use MycroZineGenerator in Create Tool submenu
- Updated overrides.tsx with MycroZineGenerator tool definition
- Removed all references to old MycrozineTemplate

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 19:44:08 -05:00
Jeff Emmett 04d6859b8a feat: add MycroZine Generator shape with 5-phase workflow
Implements interactive 8-page zine creation tool:
- Phase 1: Ideation - chat UI for topic/content planning
- Phase 2: Drafts - generates 8 pages, spawns on canvas
- Phase 3: Feedback - approve/edit individual pages
- Phase 4: Finalizing - regenerate pages with feedback
- Phase 5: Complete - print layout download, template save

Features:
- Style selector (punk-zine, minimal, collage, retro, academic)
- Tone selector (rebellious, playful, informative, poetic)
- Chat-based ideation workflow
- Page grid with approval/feedback UI
- LocalStorage template persistence
- Punk green (#00ff00) theme

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 19:35:28 -05:00
Jeff Emmett 859851c460 Update task task-055 2025-12-15 19:34:43 -05:00
Jeff Emmett 26a94fbef0 Update task task-055 2025-12-15 19:27:36 -05:00
Jeff Emmett 2a3406e8c4 fix: improve backwards compatibility for older JSON imports
- Add validation for highlight shapes (same as draw)
- Improve segment validation to check for NaN/Infinity in point coordinates
- Add more custom shape types to valid shapes list
- Fix arrow shape validation (use start/end props instead of points array)
- Fix line shape validation (uses object format for points, not array)
- Better error messages for invalid shapes

Prevents "No nearest point found" errors when importing older files
with malformed path geometry data.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 19:13:20 -05:00
Jeff Emmett 0753656c9a perf: optimize bundle size with lazy loading and dependency removal
- Add route-level lazy loading for all pages (Default, Board, Dashboard, etc.)
- Remove gun, webnative, holosphere dependencies (175 packages removed)
- Stub HoloSphereService for future Nostr integration (keeps h3-js for holon calculations)
- Stub FileSystemContext (webnative removed)
- Defer Daily.co initialization until needed
- Add loading spinner for route transitions
- Remove large-utils manual chunk from vite config

Initial page load significantly reduced - heavy Board component (7.5MB)
now loads on-demand instead of upfront.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 18:59:52 -05:00
Jeff Emmett 10a871d022 Create task task-055 2025-12-15 18:41:25 -05:00
Jeff Emmett e42ef38878 Update task task-053 2025-12-15 18:41:25 -05:00
Jeff Emmett 6d2bce3582 Create task task-053 2025-12-15 18:41:10 -05:00
Jeff Emmett d61ab79094 Update task task-054 2025-12-15 18:40:44 -05:00
Jeff Emmett 63d6759a8c Update task task-053 2025-12-15 18:40:44 -05:00
Jeff Emmett 3e46b85f40 Create task task-054 2025-12-15 18:40:33 -05:00
Jeff Emmett d15eee2ee2 Create task task-053 2025-12-15 18:40:33 -05:00
Jeff Emmett 2d9d216bc7 feat: re-enable Map tool and add GPS location sharing
- Re-enable Map tool in CustomToolbar and CustomContextMenu
- Add GPS location sharing state and UI to MapShapeUtil
- Show collaborator locations on map with colored markers
- Add toggle button to share/stop sharing your location
- Cleanup GPS watch and markers on component unmount

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 18:39:26 -05:00
Jeff Emmett df80a3f616 fix: resolve user identity caching issues on logout/login
- Preserve tldraw-user-id-* and crypto keys on logout (prevents duplicate cursors)
- Add session-logged-in event for immediate tool enabling after login
- Add session-cleared event for component state cleanup
- Clear only session-specific data (permissions, graph cache, room ID)
- CryptIDDropdown: reset connections state on logout
- useNetworkGraph: clear graph cache on logout

The key fix is preserving tldraw user IDs across login/logout cycles.
Previously, clearing these IDs caused each login to create a new presence
record while old ones persisted in Automerge, resulting in stacked cursors.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 18:34:41 -05:00
Jeff Emmett 0a95c31974 feat: improve keyboard shortcuts UI with Command Palette
- Add openCommandPalette() export function for manual triggering
- Update ? button to open the colorful Command Palette modal instead of dropdown
- Add support for manual opening with Escape and click-outside to close
- Clean up unused shortcut dropdown code and state
- Maintain Ctrl+Shift hold behavior for quick access

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 16:31:35 -05:00
Jeff Emmett c314edac0c Update task task-052 2025-12-15 14:26:10 -05:00
Jeff Emmett a46ce44375 fix: use verified jeffemmett.com domain for admin request emails
Changed from email from 'noreply@canvas.jeffemmett.com' (unverified) to
'Canvas <noreply@jeffemmett.com>' (verified in Resend).

Also added RESEND_API_KEY secret to Cloudflare Worker.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 14:25:44 -05:00
Jeff Emmett bfacc55486 Update task task-052 2025-12-15 13:32:12 -05:00
Jeff Emmett 3f71222bf9 feat: integrate board protection settings into existing settings dropdown
- Remove separate BoardSettingsDropdown button from UI panel
- Add board protection toggle and editor management to existing settings dropdown
- Show protection section only for admins (board owner or global admin)
- Add ability to toggle view-only mode for protected boards
- Add editor management UI with invite and remove functionality
- Fix TypeScript type annotations for API responses

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 13:05:45 -05:00
Jeff Emmett a178623e42 feat: add BoardSettingsDropdown to top-right UI panel
Added the board settings dropdown between ShareBoardButton and StarBoardButton.
Provides access to:
- Board protection toggle (view-only mode)
- Editor management for protected boards
- Admin request functionality

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 12:54:12 -05:00
Jeff Emmett 5a57965046 Update task task-052 2025-12-15 12:45:46 -05:00
Jeff Emmett 2fe96faf53 feat: flip permissions model - everyone edits by default, protected boards opt-in
NEW PERMISSION MODEL:
- All users (including anonymous) can now EDIT by default
- Boards can be marked as "protected" by admin - only listed editors can edit
- Global admins (jeffemmett@gmail.com) have admin on ALL boards
- Added BoardSettingsDropdown with view-only toggle for admins

Backend changes:
- Added is_protected column to boards table
- Added global_admins table
- New getEffectivePermission logic prioritizes: token > global admin > owner > protection status
- New API endpoints: /auth/global-admin-status, /admin/request, /boards/:id/info, /boards/:id/editors
- Admin request sends email via Resend API

Frontend changes:
- BoardSettingsDropdown component with protection toggle and editor management
- Updated AuthContext and Board.tsx to default to 'edit' permission
- isReadOnly now only true for protected boards where user is not an editor

Task: task-052

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 12:43:14 -05:00
Jeff Emmett 6b84d7109c Create task task-052 2025-12-15 12:23:11 -05:00
Jeff Emmett 91093f706f feat: add Drawfast tool, improve share UI, and various UI enhancements
New features:
- Add Drawfast tool and shape for quick drawing
- Add useLiveImage hook for real-time image generation
- Improve ShareBoardButton with better UI and functionality

UI improvements:
- Refactor CryptIDDropdown for cleaner interface
- Enhance components.tsx with better tool visibility handling
- Add context menu and toolbar enhancements
- Update MycelialIntelligenceBar styling

Backend:
- Add board permissions API endpoints
- Update worker with new networking routes
- Add html2canvas dependency

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 00:03:12 -05:00
Jeff Emmett 626920966a feat: improve social network presence handling and cleanup
- Add "(you)" indicator on tooltip when hovering current user's node
- Ensure current user always appears in graph even with no connections
- Add new participants immediately to graph (no 30s delay)
- Implement "leave" message protocol for presence cleanup:
  - Client sends leave message before disconnecting
  - Server broadcasts leave to other clients on disconnect
  - Clients remove presence records on receiving leave
- Generate consistent user colors from CryptID username (not session ID)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 00:01:28 -05:00
Jeff Emmett 00994fd857 Update task task-051 2025-12-14 23:58:34 -05:00
Jeff Emmett 8df3a1a675 Create task task-051 2025-12-14 23:58:28 -05:00
Jeff Emmett 4df9e42e4e feat: add offline storage fallback for browser reload
When the browser reloads without network connectivity, the canvas now
automatically loads from local IndexedDB storage and renders the last
known state.

Changes:
- Board.tsx: Updated render condition to allow rendering when offline
  with local data (isOfflineWithLocalData flag)
- useAutomergeStoreV2: Added isNetworkOnline parameter and offline fast
  path that immediately loads records from Automerge doc without waiting
  for network patches
- useAutomergeSyncRepo: Passes isNetworkOnline to useAutomergeStoreV2
- ConnectionStatusIndicator: Updated messaging to clarify users are
  viewing locally cached canvas when offline

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 23:57:26 -05:00
Jeff Emmett e2d9382202 Create task task-050 2025-12-14 13:32:20 -05:00
Jeff Emmett e4b767625c feat: add user dropdown menu, fix auth tool visibility, improve network graph
- Add dropdown menu when clicking user nodes in network graph with options:
  - Connect with <username>
  - Navigate to <username> (pan to cursor)
  - Screenfollow <username> (follow camera)
  - Open <username>'s profile
- Fix tool visibility for logged-in users (timing issue with read-only mode)
- Fix 401 errors by correcting localStorage key from 'cryptid_session' to 'canvas_auth_session'
- Remove "(anonymous)" suffix from usernames in tooltips
- Simplify node colors to use user's profile/presence color
- Clear permission cache on logout to prevent stale state
- Various UI improvements to auth components and network graph

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 18:41:53 -05:00
Jeff Emmett 1136047795 Update task task-017 2025-12-11 07:15:44 -08:00
Jeff Emmett 7309ef9a7f Create task task-049 2025-12-10 14:24:07 -08:00
Jeff Emmett 982157f07d Update task task-048 2025-12-10 14:22:25 -08:00
Jeff Emmett e72813f573 Create task task-048 2025-12-10 14:22:15 -08:00
Jeff Emmett 195cc7f86e feat: add version history, Resend email, CryptID registration flow
- Switch email service from SendGrid to Resend
- Add multi-step CryptID registration with passwordless explainer
- Add email backup for multi-device account access
- Add version history API endpoints (history, snapshot, diff, revert)
- Create VersionHistoryPanel UI with diff visualization
  - Green highlighting for added shapes
  - Red highlighting for removed shapes
  - Purple highlighting for modified shapes
- Fix network graph connect/trust buttons
- Enhance CryptID dropdown with better integration buttons
- Add Obsidian vault connection modal

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 14:21:50 -08:00
Jeff Emmett 9c612cb274 Update task task-047 2025-12-10 10:28:27 -08:00
Jeff Emmett c396c1901e Create task task-047 2025-12-10 10:28:22 -08:00
Jeff Emmett b6af3ecba8 feat: improve mobile touch/pen interactions across custom tools
- Add onTouchStart/onTouchEnd handlers to all interactive elements
- Add touchAction: 'manipulation' CSS to prevent 300ms click delay
- Increase minimum touch target sizes to 44px for accessibility
- Fix ImageGen: Generate button, Copy/Download/Delete, input field
- Fix VideoGen: Upload, URL input, prompt, duration, Generate button
- Fix Transcription: Start/Stop/Pause buttons, textarea, Save/Cancel
- Fix Multmux: Create Session, Refresh, session list, input fields

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 10:27:44 -08:00
Jeff Emmett 96887c8ba1 Update task task-046 2025-12-08 01:03:18 -08:00
Jeff Emmett b4c4d36e56 feat: add maximize button to all tool shapes
Add useMaximize hook to all shapes using StandardizedToolWrapper:
- MapShapeUtil, MultmuxShapeUtil, MarkdownShapeUtil
- ObsNoteShapeUtil, ImageGenShapeUtil, VideoGenShapeUtil
- HolonShapeUtil, PromptShapeUtil, EmbedShapeUtil
- FathomMeetingsBrowserShapeUtil, FathomNoteShapeUtil
- HolonBrowserShapeUtil, ObsidianBrowserShapeUtil
- TranscriptionShapeUtil, VideoChatShapeUtil

All tools now have maximize/fullscreen functionality via the
standardized header bar.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 01:02:57 -08:00
Jeff Emmett b63491f19a Create task task-046 2025-12-08 00:51:43 -08:00
Jeff Emmett 7420d18d36 feat: add maximize button to StandardizedToolWrapper
- Add maximize/fullscreen button to standardized header bar
- Create useMaximize hook for shape utils to enable fullscreen
- Shape fills viewport when maximized, restores on Esc or toggle
- Implement on ChatBoxShapeUtil as example (other shapes can add easily)
- Button shows ⤢ for maximize, ⊡ for exit fullscreen

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 00:51:23 -08:00
Jeff Emmett 7e2a69d4a5 Create task task-045 2025-12-08 00:48:02 -08:00
Jeff Emmett 351e437ec1 fix: offline-first loading from IndexedDB when server is down
- Remove blocking await adapter.whenReady() that prevented offline mode
- Load from IndexedDB immediately without waiting for network
- Set handle and mark as ready BEFORE network sync for instant UI
- Background server sync with 5-second timeout
- Continue with local data if network is unavailable

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 00:48:02 -08:00
Jeff Emmett 1d51352748 Update task task-044 2025-12-08 00:48:02 -08:00
Jeff Emmett a6b3024a99 feat: add invite/share feature with QR code, URL, NFC, and audio connect
- Add InviteDialog component with tabbed interface for sharing boards
- Add ShareBoardButton component to toolbar
- Integrate qrcode.react for QR code generation
- Implement Web NFC API for NFC tag writing
- Add placeholder for audio connect feature (coming soon)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 05:35:30 +01:00
Jeff Emmett 6d8180cf40 feat: unified top-right menu with grey oval container
- Created single grey oval container for all top-right menu items
- Order: CryptID -> Star -> Gear -> Question mark
- Added vertical separator lines between each menu item
- Consistent styling with rounded container and subtle shadow
- Removed separate styling for individual buttons

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:17:17 -08:00
Jeff Emmett 24f298e195 feat: dark theme social network graph with arrows + responsive MI bar
Social Network Graph:
- Dark/black theme with semi-transparent background
- Arrow markers on edges showing connection direction
- Color-coded arrows: grey (default), yellow (connected), green (trusted)
- Updated header, stats, and icon button colors for dark theme

MI (Mycelial Intelligence) Bar:
- Responsive width: full width on mobile, percentage on narrow, fixed on desktop
- Position: moves to bottom on mobile (above toolbar), stays at top on desktop
- Smooth transitions when resizing
- Smaller max height on mobile (300px vs 400px)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:09:33 -08:00
Jeff Emmett f921c5229a fix: graceful fallback for network graph API errors + Map fixes
Network Graph:
- Add graceful fallback when API returns 401 or other errors
- Falls back to showing room participants as nodes
- Prevents error spam in console for unauthenticated users

Map Shape (linter changes):
- Add isFetchingNearby state for loading indicator
- Improve addAnnotation to accept name/color options
- Add logging for Find Nearby debugging

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 16:47:19 -08:00
Jeff Emmett 5f9bea85bb Create task task-044 2025-12-07 15:26:04 -08:00
Jeff Emmett 8123f0fadd feat: redesign top-right UI, fix Map interactions and schema validation
UI Changes:
- Add CryptIDDropdown component with Google integration under Integrations
- Remove user presence avatars (moved to network graph)
- New top-right layout: CryptID -> Star -> Gear dropdown -> Question mark
- Settings gear shows dropdown with dark mode toggle + All Settings link
- Network graph label changed to "Social Network"
- Network graph shows for all users including anonymous
- Solo users see themselves as a lone node

Map Shape Fixes:
- Fix stale closure bug: tool clicks now work using activeToolRef
- Fix wheel scroll: native event listener prevents tldraw capture
- Add pointerEvents: 'auto' to map container for proper mouse interaction

Bug Fix:
- Add Map shape sanitization in AutomergeToTLStore for pinnedToView/isMinimized
- Prevents "Expected boolean, got undefined" errors on old Map data

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 15:21:48 -08:00
Jeff Emmett fc43e8e803 Update task task-001 2025-12-07 12:50:32 -08:00
Jeff Emmett ee3ec16cb6 feat: add StandardizedToolWrapper and fix map interactions
- Wrap map component in StandardizedToolWrapper with header bar
- Add onPointerDown={stopPropagation} to all sidebar interactive elements
- Add handleMapWheel that forwards wheel zoom to map component
- Add pinnedToView, tags, isMinimized props for consistency
- Fix TypeScript type for stopPropagation handler

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 12:44:48 -08:00
Jeff Emmett dc048d7aec Merge branch 'feature/mapshapeutil-fixes' into dev
Resolve conflict by taking feature branch MapShapeUtil changes for:
- Higher z-index on map buttons
- pointer-events: auto for clickability
- handleWheel with preventDefault for map zoom

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 11:49:44 -08:00
Jeff Emmett e8984a757b fix: enable open-mapping module with TypeScript fixes
- Fix unused parameter errors (prefix with underscore)
- Fix TrustCircleManager API: add getTrustLevel/setTrustLevel methods
- Fix MyceliumNetwork method calls: addNode→createNode, addHypha→createHypha
- Fix createCommitment signature to use CommitmentParams object
- Fix GeohashCommitment type with proper geohash field
- Fix PRECISION_CELL_SIZE usage (returns {lat,lng} object)
- Add type assertions for fetch response data
- Fix MapCanvas attributionControl type
- Fix GPSCollaborationLayer markerStyle merge with defaults
- Update MapShapeUtil with better event handling:
  - Raise z-index to 10000 for all map buttons
  - Add pointerEvents: auto for button clickability
  - Add handleWheel with preventDefault to enable map zoom
  - Add capturePointerEvents for proper interaction

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 11:48:49 -08:00
Jeff Emmett f5eb29ae74 Update task task-004 2025-12-06 22:43:37 -08:00
Jeff Emmett fa212f90ea Update task task-024 2025-12-06 22:43:25 -08:00
Jeff Emmett ecb3dfd646 fix: MapShapeUtil cleanup errors and schema validation
- Add isMountedRef to track component mount state
- Fix map initialization cleanup with named event handlers
- Add try/catch blocks for all MapLibre operations
- Fix style change, resize, and annotations effects with mounted checks
- Update callbacks (observeUser, selectSearchResult, findNearby) with null checks
- Add legacy property support (interactive, showGPS, showSearch, showDirections, sharingLocation, gpsUsers)
- Prevents 'getLayer' and 'map' undefined errors during component unmount
- Complete Mapus-style UI with sidebar, search, find nearby, annotations, and drawing tools

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 22:42:59 -08:00
Jeff Emmett 351f7996b9 fix: MapShapeUtil cleanup errors and schema validation
- Add isMountedRef to track component mount state
- Fix map initialization cleanup with named event handlers
- Add try/catch blocks for all MapLibre operations
- Fix style change, resize, and annotations effects with mounted checks
- Update callbacks (observeUser, selectSearchResult, findNearby) with null checks
- Add legacy property support (interactive, showGPS, showSearch, showDirections, sharingLocation, gpsUsers)
- Prevents 'getLayer' and 'map' undefined errors during component unmount
- Complete Mapus-style UI with sidebar, search, find nearby, annotations, and drawing tools

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 22:39:45 -08:00
Jeff Emmett e701b04d56 Update task task-024 2025-12-06 22:32:53 -08:00
Jeff Emmett f1c7df5699 Create task task-043 2025-12-06 22:31:37 -08:00
Jeff Emmett a60c2c0899 Update task task-024 2025-12-06 22:21:50 -08:00
Jeff Emmett df117acc94 Update task task-024 2025-12-05 23:22:36 -08:00
Jeff Emmett 71c0059c9a feat: add canvas users to CryptID connections dropdown
Shows all collaborators currently on the canvas with their connection status:
- Green border: Trusted (edit access)
- Yellow border: Connected (view access)
- Grey border: Not connected

Users can:
- Add unconnected users as Connected or Trusted
- Upgrade Connected users to Trusted
- Downgrade Trusted users to Connected
- Remove connections

Also fixes TypeScript errors in networking module.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 23:08:16 -08:00
Jeff Emmett 76ec56a5f4 Update task task-027 2025-12-05 22:55:21 -08:00
Jeff Emmett fab58aead0 fix: correct networking imports and API response format
- Fix useSession → useAuth import (matches actual export)
- Fix GraphEdge properties: source/target instead of fromUserId/toUserId
- Add missing trustLevel, effectiveTrustLevel to edge response
- Add myConnections to NetworkGraph type
- Prefix unused myConnections param with underscore

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-05 22:51:58 -08:00
Jeff Emmett b95eb6dc01 Update task task-041 2025-12-05 22:46:57 -08:00
Jeff Emmett eb7498157f Create task task-042 2025-12-05 22:46:50 -08:00
Jeff Emmett 30608dfdc8 feat: integrate read-only mode for board permissions
- Add permission fetching and state management in Board.tsx
- Fetch user's permission level when board loads
- Set tldraw to read-only mode when user has 'view' permission
- Show AnonymousViewerBanner for unauthenticated users
- Banner prompts CryptID sign-up with your specified messaging
- Update permission state when user authenticates
- Wire up permission API routes in worker/worker.ts
  - GET /boards/:boardId/permission
  - GET /boards/:boardId/permissions (admin)
  - POST /boards/:boardId/permissions (admin)
  - DELETE /boards/:boardId/permissions/:userId (admin)
  - PATCH /boards/:boardId (admin)
- Add X-CryptID-PublicKey to CORS allowed headers
- Add PUT, PATCH, DELETE to CORS allowed methods

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-05 22:45:31 -08:00
Jeff Emmett 9f5befc729 Update task task-024 2025-12-05 22:40:20 -08:00
Jeff Emmett fe0a96ddad Update task task-018 2025-12-05 22:39:25 -08:00
Jeff Emmett e11eccd34c Update task task-041 2025-12-05 22:38:33 -08:00
Jeff Emmett c00106e2b7 feat: implement user permissions system (view/edit/admin)
Phase 1 of user permissions feature:
- Add board permissions schema to D1 database
  - boards table with owner, default_permission, is_public
  - board_permissions table for per-user permissions
- Add permission types (PermissionLevel) to worker and client
- Implement permission API handlers in worker/boardPermissions.ts
  - GET /boards/:boardId/permission - check user's permission
  - GET /boards/:boardId/permissions - list all (admin only)
  - POST /boards/:boardId/permissions - grant permission (admin)
  - DELETE /boards/:boardId/permissions/:userId - revoke (admin)
  - PATCH /boards/:boardId - update board settings (admin)
- Update AuthContext with permission fetching and caching
  - fetchBoardPermission() - fetch and cache permission for a board
  - canEdit() - check if user can edit current board
  - isAdmin() - check if user is admin for current board
- Create AnonymousViewerBanner component with CryptID signup prompt
- Add CSS styles for anonymous viewer banner
- Fix automerge sync manager to flush saves on peer disconnect

Permission levels:
- view: Read-only, cannot create/edit/delete shapes
- edit: Can modify board contents
- admin: Full access + permission management

Next steps:
- Integrate with Board component for read-only mode
- Wire up permission checking in Automerge sync
- Add permission management UI for admins

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-05 22:27:12 -08:00
Jeff Emmett 037e232b85 Update task task-041 2025-12-05 22:24:37 -08:00
Jeff Emmett 14bf688b60 Create task task-041 2025-12-05 22:17:54 -08:00
Jeff Emmett af6666bf72 Update task task-027 2025-12-05 14:05:24 -08:00
Jeff Emmett d4df704c86 Create task task-040 2025-12-05 13:58:56 -08:00
Jeff Emmett d49f7486e2 chore: exclude open-mapping from build, fix TypeScript errors
- Add src/open-mapping/** to tsconfig exclude (21K lines, to harden later)
- Delete MapShapeUtil.backup.tsx
- Fix ConnectionStatus type in OfflineIndicator
- Fix data type assertions in MapShapeUtil (routing/search)
- Fix GoogleDataService.authenticate() call with required param
- Add ts-expect-error for Automerge NetworkAdapter 'ready' event
- Add .wasm?module type declaration for Wrangler imports
- Include GPS location sharing enhancements in MapShapeUtil

TypeScript now compiles cleanly. Vite build needs NODE_OPTIONS for memory.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 12:16:29 -08:00
Jeff Emmett 36ce0f3fb2 Update task task-024 2025-12-04 21:35:10 -08:00
Jeff Emmett e778e20bae chore: add D1 database ID and refactor MapShape
- Add production D1 database ID for cryptid-auth
- Refactor MapShapeUtil for cleaner implementation
- Add map layers module
- Update UI components

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 21:32:46 -08:00
Jeff Emmett e2a9f3ba54 Update task task-024 2025-12-04 21:29:10 -08:00
Jeff Emmett 254eeda94e Update task task-024 2025-12-04 20:01:59 -08:00
Jeff Emmett 857879cb7e Update task task-027 2025-12-04 19:53:01 -08:00
Jeff Emmett 7677595708 Update task task-037 2025-12-04 19:52:54 -08:00
Jeff Emmett 2418e5065f Update task task-024 2025-12-04 19:52:54 -08:00
Jeff Emmett 9b06bfadb3 Merge feature/open-mapping: Automerge CRDT sync and open-mapping module
Key changes:
- Automerge CRDT infrastructure for offline-first sync
- Open-mapping collaborative route planning module
- MapShape integration with canvas
- Connection status indicator
- Binary document persistence in R2
- Resolved merge conflicts with PrivateWorkspace feature

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 19:48:38 -08:00
Jeff Emmett 4a9312fa52 Update task task-024 2025-12-04 19:45:28 -08:00
Jeff Emmett 856bfa8e9b feat: implement binary Automerge CRDT sync and open-mapping module
Binary Automerge Sync:
- CloudflareAdapter: binary sync messages with documentId tracking
- Message buffering for early server messages before documentId set
- Worker sends initial sync on WebSocket connect
- Removed JSON HTTP POST sync in favor of native Automerge protocol
- Multi-client binary sync verified working

Worker CRDT Infrastructure:
- automerge-init.ts: WASM initialization for Cloudflare Workers
- automerge-sync-manager.ts: sync state management per peer
- automerge-r2-storage.ts: binary document persistence to R2
- AutomergeDurableObject: integrated CRDT sync handling

Open Mapping Module:
- Collaborative map component with real-time sync
- MapShapeUtil for tldraw canvas integration
- Presence layer with location sharing
- Privacy system with ZK-GPS protocol concepts
- Mycelium network for organic route visualization
- Conic sections for map projection optimization
- Discovery system (spores, hunts, collectibles, anchors)
- Geographic transformation utilities

UI Updates:
- ConnectionStatusIndicator for offline/sync status
- Map tool in toolbar
- Context menu updates

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 19:45:02 -08:00
Jeff Emmett 9473a49ebb Update task task-027 2025-12-04 19:42:48 -08:00
Jeff Emmett c93460804d Update task task-005 2025-12-04 19:41:18 -08:00
Jeff Emmett 9db6fde5a5 Update task task-039 2025-12-04 19:41:04 -08:00
Jeff Emmett a347df11a5 Update task task-039 2025-12-04 19:40:55 -08:00
Jeff Emmett 8892a9cf3a feat: add Google integration to user dropdown and keyboard shortcuts panel
- Add Google Workspace integration directly in user dropdown (CustomPeopleMenu)
  - Shows connection status (Connected/Not Connected)
  - Connect button to trigger OAuth flow
  - Browse Data button to open GoogleExportBrowser modal
- Add toggleable keyboard shortcuts panel (? icon)
  - Shows full names of tools and actions with their shortcuts
  - Organized by category: Tools, Custom Tools, Actions, Custom Actions
  - Toggle on/off by clicking, closes when clicking outside
- Import GoogleExportBrowser component for data browsing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 19:21:21 -08:00
Jeff Emmett abca1ea9d2 Update task task-039 2025-12-04 18:45:13 -08:00
Jeff Emmett bc1d0421c6 Update task task-039 2025-12-04 18:35:47 -08:00
Jeff Emmett 09de6438c0 Update task task-039 2025-12-04 18:28:48 -08:00
Jeff Emmett 6937bf09b8 Update task task-039 2025-12-04 18:21:01 -08:00
Jeff Emmett b3a7ff2b0c Create task task-039 2025-12-04 18:12:01 -08:00
Jeff Emmett c0f30ea29a Update task task-038 2025-12-04 18:00:58 -08:00
Jeff Emmett f28bfd9340 Create task task-038 2025-12-04 18:00:52 -08:00
Jeff Emmett 4da7d4b1e7 Update task task-035 2025-12-04 18:00:10 -08:00
Jeff Emmett 09bce4dd94 feat: implement Phase 5 - permission flow and drag detection for data sovereignty
- Add VisibilityChangeModal for confirming visibility changes
- Add VisibilityChangeManager to handle events and drag detection
- GoogleItem shapes now dispatch visibility change events on badge click
- Support both local->shared and shared->local transitions
- Auto-detect when GoogleItems are dragged outside PrivateWorkspace
- Session storage for "don't ask again" preference

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 17:59:58 -08:00
Jeff Emmett 815fc04366 Update task task-025 2025-12-04 17:53:08 -08:00
Jeff Emmett 84c6bf834c feat: Add GoogleItemShape with privacy badges (Phase 4)
Privacy-aware item shapes for Google Export data:

- GoogleItemShapeUtil: Custom shape for Google items with:
  - Visual distinction: dashed border + shaded overlay for LOCAL items
  - Solid border for SHARED items
  - Privacy badge (🔒 local, 🌐 shared) in top-right corner
  - Click badge to trigger visibility change (Phase 5)
  - Service icon, title, preview, date display
  - Optional thumbnail support for photos
  - Dark mode support

- GoogleItemTool: Tool for creating GoogleItem shapes

- Updated ShareableItem type to include `service` and `thumbnailUrl`

- Updated usePrivateWorkspace hook to create GoogleItem shapes
  instead of placeholder text shapes

Items added from GoogleExportBrowser now appear as proper
GoogleItem shapes with privacy indicators inside the workspace.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 17:52:54 -08:00
Jeff Emmett d279daa9eb Update task task-032 2025-12-04 17:42:07 -08:00
Jeff Emmett dcd1ed326b Update task task-024 2025-12-04 17:41:56 -08:00
Jeff Emmett 565e14ad34 Update task task-037 2025-12-04 17:41:42 -08:00
Jeff Emmett 641a3467a3 Update task task-037 2025-12-04 17:01:26 -08:00
Jeff Emmett 4416286087 Update task task-025 2025-12-04 16:54:39 -08:00
Jeff Emmett 052c98417d feat: Add Private Workspace zone for data sovereignty (Phase 3)
- PrivateWorkspaceShapeUtil: Frosted glass container shape with:
  - Dashed indigo border for visual distinction
  - Pin/collapse/close buttons in header
  - Dark mode support
  - Position/size persistence to localStorage
  - Helper functions for zone detection

- PrivateWorkspaceTool: Tool for creating workspace zones

- usePrivateWorkspace hook:
  - Creates/toggles workspace visibility
  - Listens for 'add-google-items-to-canvas' events
  - Places items inside the private zone
  - Persists visibility state

- PrivateWorkspaceManager: Headless component that manages
  workspace lifecycle inside Tldraw context

Items added from GoogleExportBrowser will now appear in the
Private Workspace zone as placeholder text shapes (Phase 4
will add proper GoogleItemShape with visual badges).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 16:54:27 -08:00
Jeff Emmett 6d963f62f1 Create task task-037 2025-12-04 16:49:08 -08:00
Jeff Emmett 58671a1c0b Update task task-025 2025-12-04 16:46:41 -08:00
Jeff Emmett 069ba1510c Update task task-033 2025-12-04 16:46:28 -08:00
Jeff Emmett 33f5dc7e7f refactor: Rename GoogleDataBrowser to GoogleExportBrowser
- Rename component file and interface for consistent naming
- Update all imports and state variables in UserSettingsModal
- Better reflects the purpose as a data export browser

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 16:46:10 -08:00
Jeff Emmett c1acf34ccd Create task task-036 2025-12-04 16:45:11 -08:00
Jeff Emmett 06c9e48999 Update task task-035 2025-12-04 16:41:01 -08:00
Jeff Emmett a754ffab57 feat(components): add GoogleDataBrowser popup modal
Phase 2 of Data Sovereignty Zone implementation:
- Create GoogleDataBrowser component with service tabs (Gmail, Drive, Photos, Calendar)
- Searchable item list with checkboxes for multi-select
- Select All/Clear functionality
- Dark mode support with consistent styling
- "Add to Private Workspace" button
- Privacy note explaining local-only encryption
- Emits 'add-google-items-to-canvas' event for Board.tsx integration

Integration with UserSettingsModal:
- Import and render GoogleDataBrowser when "Open Data Browser" clicked
- Handler for adding selected items to canvas

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 16:40:52 -08:00
Jeff Emmett 2b5a1736d7 Update task task-035 2025-12-04 16:33:48 -08:00
Jeff Emmett c9c8c008b2 feat(settings): add Google Workspace integration card
Phase 1 of Data Sovereignty Zone implementation:
- Add Google Workspace section to Settings > Integrations tab
- Show connection status, import counts (emails, files, photos, events)
- Connect/Disconnect Google account buttons
- "Open Data Browser" button (Phase 2 will implement the browser)
- Add getStoredCounts() and getInstance() to GoogleDataService

Privacy messaging: "Your data is encrypted with AES-256 and stored
only in your browser. Choose what to share to the board."

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 16:33:39 -08:00
Jeff Emmett 8bc3924a10 Merge origin/main into feature/google-export
Bring in all the latest changes from main including:
- Index validation and migration for tldraw shapes
- UserSettingsModal with integrations tab
- CryptID authentication updates
- AI services (image gen, video gen, mycelial intelligence)
- Automerge sync improvements
- Various UI improvements

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 16:29:34 -08:00
Jeff Emmett 35b5d22c4e Update task task-031 2025-12-04 15:42:51 -08:00
Jeff Emmett cd9b36dc21 Update task task-031 2025-12-04 15:37:16 -08:00
Jeff Emmett 47bd1044a1 Update task task-030 2025-12-04 15:37:02 -08:00
Jeff Emmett d9fac31a7a Create task task-035 2025-12-04 15:36:08 -08:00
Jeff Emmett da9467cdac Update task task-030 2025-12-04 15:30:25 -08:00
Jeff Emmett 686dc7c705 Update task task-029 2025-12-04 15:29:05 -08:00
Jeff Emmett eadef4ee36 Create task task-034 2025-12-04 15:24:43 -08:00
Jeff Emmett c42b986e3d Update task task-025 2025-12-04 15:24:32 -08:00
Jeff Emmett 985221d848 Update task task-033 2025-12-04 15:23:14 -08:00
Jeff Emmett e69ed0e867 feat: implement Google Data Sovereignty module for local-first data control
Core modules:
- encryption.ts: WebCrypto AES-256-GCM, HKDF key derivation, PKCE utilities
- database.ts: IndexedDB schema for gmail, drive, photos, calendar
- oauth.ts: OAuth 2.0 PKCE flow with encrypted token storage
- share.ts: Create tldraw shapes from encrypted data
- backup.ts: R2 backup service with encrypted manifest

Importers:
- gmail.ts: Gmail import with pagination and batch storage
- drive.ts: Drive import with folder navigation, Google Docs export
- photos.ts: Photos thumbnail import (403 issue pending investigation)
- calendar.ts: Calendar import with date range filtering

Test interface at /google route for debugging OAuth flow.

Known issue: Photos API returning 403 on some thumbnail URLs - needs
further investigation with proper OAuth consent screen setup.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 15:22:40 -08:00
Jeff Emmett 377b8f0bca Update task task-029 2025-12-04 15:21:13 -08:00
Jeff Emmett fb6697a052 Update task task-033 2025-12-04 15:01:40 -08:00
Jeff Emmett ebb3ab661b Create task task-033 2025-12-04 13:44:43 -08:00
Jeff Emmett 477af6ae05 Update task task-028 2025-12-04 13:44:04 -08:00
Jeff Emmett 36ea5e5482 Update task task-028 2025-12-04 13:34:28 -08:00
Jeff Emmett f9f54f9f32 Update task task-028 2025-12-04 13:24:44 -08:00
Jeff Emmett 878227f31d Update task task-028 2025-12-04 13:12:44 -08:00
Jeff Emmett c95ece9fe5 Create task task-032 2025-12-04 13:12:10 -08:00
Jeff Emmett 538dbcd807 Create task task-031 2025-12-04 13:12:10 -08:00
Jeff Emmett f0d261ff98 Create task task-030 2025-12-04 13:12:10 -08:00
Jeff Emmett 1ec6faed56 Create task task-029 2025-12-04 13:12:09 -08:00
Jeff Emmett 37cd086ff0 Create task task-028 2025-12-04 13:12:06 -08:00
Jeff Emmett 808532a1b6 Create task task-027 2025-12-04 13:06:11 -08:00
Jeff Emmett a48708525c Update task task-025 2025-12-04 12:51:27 -08:00
Jeff Emmett c4e50f01fd Create task task-026 2025-12-04 12:48:09 -08:00
Jeff Emmett 16acd3d6ef Update task task-025 2025-12-04 12:43:47 -08:00
Jeff Emmett a50e3dad58 Update task task-001 2025-12-04 12:35:25 -08:00
Jeff Emmett 22ac1d65dd Update task task-025 2025-12-04 12:28:49 -08:00
Jeff Emmett f67ee111e6 Update task task-001 2025-12-04 12:27:04 -08:00
Jeff Emmett 63264cf636 Update task task-001 2025-12-04 12:25:53 -08:00
Jeff Emmett 0ec4e9382f Create task task-025 2025-12-04 12:25:35 -08:00
Jeff Emmett 8cda0d4e28 Merge main into feature/open-mapping, resolve conflicts 2025-12-04 06:51:35 -08:00
Jeff Emmett 8dac699acf Merge branch 'main' into feature/open-mapping 2025-12-04 06:50:37 -08:00
Jeff Emmett 4f1a6d1314 feat: add open-mapping collaborative route planning module
Introduces a comprehensive mapping and routing layer for the canvas
that provides advanced route planning capabilities beyond Google Maps.

Built on open-source foundations:
- OpenStreetMap for base map data
- OSRM/Valhalla for routing engines
- MapLibre GL JS for map rendering
- VROOM for route optimization
- Y.js for real-time collaboration

Features planned:
- Multi-path routing with alternatives comparison
- Real-time collaborative waypoint editing
- Layer management (basemaps, overlays, custom GeoJSON)
- Calendar/scheduling integration
- Budget tracking per waypoint/route
- Offline tile caching via PWA

Includes:
- TypeScript types for routes, waypoints, layers
- React hooks for map instance, routing, collaboration
- Service abstractions for multiple routing providers
- Docker Compose config for backend deployment
- Setup script for OSRM data preparation

Backlog task: task-024

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 06:39:26 -08:00
Jeff Emmett de770a4f91 docs: add data sovereignty architecture for Google imports and local file uploads
- Add GOOGLE_DATA_SOVEREIGNTY.md: comprehensive plan for secure local storage
  of Gmail, Drive, Photos, Calendar data with client-side encryption
- Add LOCAL_FILE_UPLOAD.md: multi-item upload tool with same encryption model
  for local files (images, PDFs, documents, audio, video)
- Update OFFLINE_STORAGE_FEASIBILITY.md to reference new docs

Key features:
- IndexedDB encrypted storage with AES-256-GCM
- Keys derived from WebCrypto auth (never leave browser)
- Safari 7-day eviction mitigations
- Selective sharing to boards via Automerge
- Optional encrypted R2 backup

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 04:47:22 -08:00
Jeff Emmett 6507adc36d Implement offline storage with IndexedDB for canvas documents
- Add @automerge/automerge-repo-storage-indexeddb for local persistence
- Create documentIdMapping utility to track roomId → documentId in IndexedDB
- Update useAutomergeSyncRepo with offline-first loading strategy:
  - Load from IndexedDB first for instant access
  - Sync with server in background when online
  - Track connection status (online/offline/syncing)
- Add OfflineIndicator component to show connection state
- Integrate offline indicator into Board component

Documents are now cached locally and available offline. Automerge CRDT
handles conflict resolution when syncing back online.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 03:03:37 -08:00
Jeff Emmett 7919d34dfa offline browser storage prep 2025-11-11 13:33:18 -08:00
307 changed files with 84094 additions and 6609 deletions

129
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,129 @@
name: Tests
on:
push:
branches: [dev, main]
pull_request:
branches: [dev, main]
jobs:
unit-tests:
name: Unit & Integration Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run TypeScript check
run: npm run types
- name: Run unit tests with coverage
run: npm run test:coverage
- name: Run worker tests
run: npm run test:worker
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
fail_ci_if_error: false
verbose: true
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Run E2E tests
run: npm run test:e2e
env:
CI: true
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
- name: Upload Playwright traces
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-traces
path: test-results/
retention-days: 7
build-check:
name: Build Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build project
run: npm run build
env:
NODE_OPTIONS: '--max-old-space-size=8192'
# Gate job that requires all tests to pass before merge
merge-ready:
name: Merge Ready
needs: [unit-tests, e2e-tests, build-check]
runs-on: ubuntu-latest
if: always()
steps:
- name: Check all jobs passed
run: |
if [[ "${{ needs.unit-tests.result }}" != "success" ]]; then
echo "Unit tests failed"
exit 1
fi
if [[ "${{ needs.e2e-tests.result }}" != "success" ]]; then
echo "E2E tests failed"
exit 1
fi
if [[ "${{ needs.build-check.result }}" != "success" ]]; then
echo "Build check failed"
exit 1
fi
echo "All checks passed - ready to merge!"

4
.gitignore vendored
View File

@ -176,3 +176,7 @@ dist
.dev.vars
.env.production
.aider*
# Playwright
playwright-report/
test-results/

View File

@ -14,7 +14,7 @@ RUN npm ci --legacy-peer-deps
COPY . .
# Build args for environment
ARG VITE_TLDRAW_WORKER_URL=https://jeffemmett-canvas.jeffemmett.workers.dev
ARG VITE_WORKER_ENV=production
ARG VITE_DAILY_API_KEY
ARG VITE_RUNPOD_API_KEY
ARG VITE_RUNPOD_IMAGE_ENDPOINT_ID
@ -23,7 +23,8 @@ ARG VITE_RUNPOD_TEXT_ENDPOINT_ID
ARG VITE_RUNPOD_WHISPER_ENDPOINT_ID
# Set environment for build
ENV VITE_TLDRAW_WORKER_URL=$VITE_TLDRAW_WORKER_URL
# VITE_WORKER_ENV: 'production' | 'staging' | 'dev' | 'local'
ENV VITE_WORKER_ENV=$VITE_WORKER_ENV
ENV VITE_DAILY_API_KEY=$VITE_DAILY_API_KEY
ENV VITE_RUNPOD_API_KEY=$VITE_RUNPOD_API_KEY
ENV VITE_RUNPOD_IMAGE_ENDPOINT_ID=$VITE_RUNPOD_IMAGE_ENDPOINT_ID

View File

@ -0,0 +1,236 @@
# Offline Storage Feasibility Assessment
Summary
Difficulty: Medium — Feasible with Automerge's built-in support.
Why it's feasible
Automerge Repo supports storage adapters — you can add an IndexedDB adapter alongside the network adapter
CRDT merging — Automerge handles conflict resolution automatically
Existing architecture — minimal changes needed; mainly adding a storage adapter
What needs to be done
Add IndexedDB storage adapter (easy)
Install/create an IndexedDB storage adapter
Add it to the Repo configuration
Fix document ID consistency (important)
Currently uses repo.create() which generates new IDs each time
Need to use repo.find(roomId) or store a roomId → documentId mapping
This is the main blocker for local storage persistence
Handle online/offline transitions (medium)
Listen for network status changes
Trigger sync when coming back online
The existing network adapter already handles reconnection
Update initialization logic (medium)
Load from IndexedDB first (instant offline access)
Then sync with server when online
Automerge will automatically merge changes
Estimated effort
Research & setup: 1-2 hours
Implementation: 4-6 hours
Testing: 2-3 hours
Total: ~1 day of focused work
Main challenge
The document ID consistency issue. The code comment says "We can't use repo.find() with a custom ID because Automerge requires specific document ID formats" — this needs to be resolved. Options:
Store a mapping of roomId → documentId in IndexedDB
Use a deterministic algorithm to convert roomId to a valid Automerge document ID
Use Automerge's document ID generation but persist the mapping
Benefits
Instant offline access — no loading spinners
Automatic conflict resolution — Automerge handles merging
Better UX — works seamlessly offline
Resilience — works even if server is temporarily unavailable
I've created a detailed feasibility document at OFFLINE_STORAGE_FEASIBILITY.md with implementation steps, code examples, and considerations.
Should I start implementing this, or do you want to review the feasibility document first?
## Summary
**Difficulty: Medium** - The implementation is straightforward thanks to Automerge's built-in support for storage adapters, but requires careful integration with the existing sync architecture.
## Current Architecture
1. **Client-side**: Uses `@automerge/automerge-repo` with `CloudflareNetworkAdapter` for WebSocket sync
2. **Server-side**: `AutomergeDurableObject` stores documents in R2 and handles WebSocket connections
3. **Persistence flow**:
- Client saves to worker via POST `/room/:roomId`
- Worker persists to R2 (throttled to every 2 seconds)
- Client loads initial data from server via GET `/room/:roomId`
## What's Needed
### 1. Add IndexedDB Storage Adapter (Easy)
Automerge Repo supports storage adapters out of the box. You'll need to:
- Install `@automerge/automerge-repo-storage-indexeddb` (if available) or create a custom IndexedDB adapter
- Add the storage adapter to the Repo configuration alongside the network adapter
- The Repo will automatically persist document changes to IndexedDB
**Code changes needed:**
```typescript
// In useAutomergeSyncRepo.ts
import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb"
const [repo] = useState(() => {
const adapter = new CloudflareNetworkAdapter(workerUrl, roomId, applyJsonSyncData)
const storageAdapter = new IndexedDBStorageAdapter() // Add this
return new Repo({
network: [adapter],
storage: [storageAdapter] // Add this
})
})
```
### 2. Load from Local Storage on Startup (Medium)
Modify the initialization logic to:
- Check IndexedDB for existing document data
- Load from IndexedDB first (for instant offline access)
- Then sync with server when online
- Automerge will automatically merge local and remote changes
**Code changes needed:**
```typescript
// In useAutomergeSyncRepo.ts - modify initializeHandle
const initializeHandle = async () => {
// Check if document exists in IndexedDB first
const localDoc = await repo.find(roomId) // This will load from IndexedDB if available
// Then sync with server (if online)
if (navigator.onLine) {
// Existing server sync logic
}
}
```
### 3. Handle Online/Offline Transitions (Medium)
- Detect network status changes
- When coming online, ensure sync happens
- The existing `CloudflareNetworkAdapter` already handles reconnection, but you may want to add explicit sync triggers
**Code changes needed:**
```typescript
// Add network status listener
useEffect(() => {
const handleOnline = () => {
console.log('🌐 Back online - syncing with server')
// Trigger sync - Automerge will handle merging automatically
if (handle) {
// The network adapter will automatically reconnect and sync
}
}
window.addEventListener('online', handleOnline)
return () => window.removeEventListener('online', handleOnline)
}, [handle])
```
### 4. Document ID Consistency (Important)
Currently, the code creates a new document handle each time (`repo.create()`). For local storage to work properly, you need:
- Consistent document IDs per room
- The challenge: Automerge requires specific document ID formats (like `automerge:xxxxx`)
- **Solution options:**
1. Use `repo.find()` with a properly formatted Automerge document ID (derive from roomId)
2. Store a mapping of roomId → documentId in IndexedDB
3. Use a deterministic way to generate document IDs from roomId
**Code changes needed:**
```typescript
// Option 1: Generate deterministic Automerge document ID from roomId
const documentId = `automerge:${roomId}` // May need proper formatting
const handle = repo.find(documentId) // This will load from IndexedDB or create new
// Option 2: Store mapping in IndexedDB
const storedMapping = await getDocumentIdMapping(roomId)
const documentId = storedMapping || generateNewDocumentId()
const handle = repo.find(documentId)
await saveDocumentIdMapping(roomId, documentId)
```
**Note**: The current code comment says "We can't use repo.find() with a custom ID because Automerge requires specific document ID formats" - this needs to be resolved. You may need to:
- Use Automerge's document ID generation but store the mapping
- Or use a deterministic algorithm to convert roomId to valid Automerge document ID format
## Benefits
1. **Instant Offline Access**: Users can immediately see and edit their data without waiting for server response
2. **Automatic Merging**: Automerge's CRDT nature means local and remote changes merge automatically without conflicts
3. **Better UX**: No loading spinners when offline - data is instantly available
4. **Resilience**: Works even if server is temporarily unavailable
## Challenges & Considerations
### 1. Storage Quota Limits
- IndexedDB has browser-specific limits (typically 50% of disk space)
- Large documents could hit quota limits
- **Solution**: Monitor storage usage and implement cleanup for old documents
### 2. Document ID Management
- Need to ensure consistent document IDs per room
- Current code uses `repo.create()` which generates new IDs
- **Solution**: Use `repo.find(roomId)` with a consistent ID format
### 3. Initial Load Strategy
- Should load from IndexedDB first (fast) or server first (fresh)?
- **Recommendation**: Load from IndexedDB first for instant UI, then sync with server in background
### 4. Conflict Resolution
- Automerge handles this automatically, but you may want to show users when their offline changes were merged
- **Solution**: Use Automerge's change tracking to show merge notifications
### 5. Storage Adapter Availability
- Need to verify if `@automerge/automerge-repo-storage-indexeddb` exists
- If not, you'll need to create a custom adapter (still straightforward)
## Implementation Steps
1. **Research**: Check if `@automerge/automerge-repo-storage-indexeddb` package exists
2. **Install**: Add storage adapter package or create custom adapter
3. **Modify Repo Setup**: Add storage adapter to Repo configuration
4. **Update Document Loading**: Use `repo.find()` instead of `repo.create()` for consistent IDs
5. **Add Network Detection**: Listen for online/offline events
6. **Test**: Verify offline editing works and syncs correctly when back online
7. **Handle Edge Cases**: Storage quota, document size limits, etc.
## Estimated Effort
- **Research & Setup**: 1-2 hours
- **Implementation**: 4-6 hours
- **Testing**: 2-3 hours
- **Total**: ~1 day of focused work
## Code Locations to Modify
1. `src/automerge/useAutomergeSyncRepo.ts` - Main sync hook (add storage adapter, modify initialization)
2. `src/automerge/CloudflareAdapter.ts` - Network adapter (may need minor changes for offline detection)
3. Potentially create: `src/automerge/IndexedDBStorageAdapter.ts` - If custom adapter needed
## Conclusion
This is a **medium-complexity** feature that's very feasible. Automerge's architecture is designed for this exact use case, and the main work is:
1. Adding the storage adapter (straightforward)
2. Ensuring consistent document IDs (important fix)
3. Handling online/offline transitions (moderate complexity)
The biggest benefit is that Automerge's CRDT nature means you don't need to write complex merge logic - it handles conflict resolution automatically.
---
## Related: Google Data Sovereignty
Beyond canvas document storage, we also support importing and securely storing Google Workspace data locally. See **[docs/GOOGLE_DATA_SOVEREIGNTY.md](./docs/GOOGLE_DATA_SOVEREIGNTY.md)** for the complete architecture covering:
- **Gmail** - Import and encrypt emails locally
- **Drive** - Import and encrypt documents locally
- **Photos** - Import thumbnails with on-demand full resolution
- **Calendar** - Import and encrypt events locally
Key principles:
1. **Local-first**: All data stored in encrypted IndexedDB
2. **User-controlled encryption**: Keys derived from WebCrypto auth, never leave browser
3. **Selective sharing**: Choose what to share to canvas boards
4. **Optional R2 backup**: Encrypted cloud backup (you hold the keys)
This builds on the same IndexedDB + Automerge foundation described above.

139
OPEN_MAPPING_PROJECT.md Normal file
View File

@ -0,0 +1,139 @@
# Open Mapping Project
## Overview
**Open Mapping** is a collaborative route planning module for canvas-website that provides advanced mapping functionality beyond traditional tools like Google Maps. Built on open-source foundations (OpenStreetMap, OSRM, Valhalla, MapLibre), it integrates seamlessly with the tldraw canvas environment.
## Vision
Create a "living map" that exists as a layer within the collaborative canvas, enabling teams to:
- Plan multi-destination trips with optimized routing
- Compare alternative routes visually
- Share and collaborate on itineraries in real-time
- Track budgets and schedules alongside geographic planning
- Work offline with cached map data
## Core Features
### 1. Map Canvas Integration
- MapLibre GL JS as the rendering engine
- Seamless embedding within tldraw canvas
- Pan/zoom synchronized with canvas viewport
### 2. Multi-Path Routing
- Support for multiple routing profiles (car, bike, foot, transit)
- Side-by-side route comparison
- Alternative route suggestions
- Turn-by-turn directions with elevation profiles
### 3. Collaborative Editing
- Real-time waypoint sharing via Y.js/CRDT
- Cursor presence on map
- Concurrent route editing without conflicts
- Share links for view-only or edit access
### 4. Layer Management
- Multiple basemap options (OSM, satellite, terrain)
- Custom overlay layers (GeoJSON import)
- Route-specific layers (cycling, hiking trails)
### 5. Calendar Integration
- Attach time windows to waypoints
- Visualize itinerary timeline
- Sync with external calendars (iCal export)
### 6. Budget Tracking
- Cost estimates per route (fuel, tolls)
- Per-waypoint expense tracking
- Trip budget aggregation
### 7. Offline Capability
- Tile caching for offline use
- Route pre-computation and storage
- PWA support
## Technology Stack
| Component | Technology | License |
|-----------|------------|---------|
| Map Renderer | MapLibre GL JS | BSD-3 |
| Base Maps | OpenStreetMap | ODbL |
| Routing Engine | OSRM / Valhalla | BSD-2 / MIT |
| Optimization | VROOM | BSD |
| Collaboration | Y.js | MIT |
## Implementation Phases
### Phase 1: Foundation (MVP)
- [ ] MapLibre GL JS integration with tldraw
- [ ] Basic waypoint placement and rendering
- [ ] Single-route calculation via OSRM
- [ ] Route polyline display
### Phase 2: Multi-Route & Comparison
- [ ] Alternative routes visualization
- [ ] Route comparison panel
- [ ] Elevation profile display
- [ ] Drag-to-reroute functionality
### Phase 3: Collaboration
- [ ] Y.js integration for real-time sync
- [ ] Cursor presence on map
- [ ] Share link generation
### Phase 4: Layers & Customization
- [ ] Layer panel UI
- [ ] Multiple basemap options
- [ ] Overlay layer support
### Phase 5: Calendar & Budget
- [ ] Time window attachment
- [ ] Budget tracking per waypoint
- [ ] iCal export
### Phase 6: Optimization & Offline
- [ ] VROOM integration for TSP/VRP
- [ ] Tile caching via Service Worker
- [ ] PWA manifest
## File Structure
```
src/open-mapping/
├── index.ts # Public exports
├── types/index.ts # TypeScript definitions
├── components/
│ ├── MapCanvas.tsx # Main map component
│ ├── RouteLayer.tsx # Route rendering
│ ├── WaypointMarker.tsx # Interactive markers
│ └── LayerPanel.tsx # Layer management UI
├── hooks/
│ ├── useMapInstance.ts # MapLibre instance
│ ├── useRouting.ts # Route calculation
│ ├── useCollaboration.ts # Y.js sync
│ └── useLayers.ts # Layer state
├── services/
│ ├── RoutingService.ts # Multi-provider routing
│ ├── TileService.ts # Tile management
│ └── OptimizationService.ts # VROOM integration
└── utils/index.ts # Helper functions
```
## Docker Deployment
Backend services deploy to `/opt/apps/open-mapping/` on Netcup RS 8000:
- **OSRM** - Primary routing engine
- **Valhalla** - Extended routing with transit/isochrones
- **TileServer GL** - Vector tiles
- **VROOM** - Route optimization
See `open-mapping.docker-compose.yml` for full configuration.
## References
- [OSRM Documentation](https://project-osrm.org/docs/v5.24.0/api/)
- [Valhalla API](https://valhalla.github.io/valhalla/api/)
- [MapLibre GL JS](https://maplibre.org/maplibre-gl-js-docs/api/)
- [VROOM Project](http://vroom-project.org/)
- [Y.js Documentation](https://docs.yjs.dev/)

View File

@ -1,12 +1,54 @@
---
id: task-001
title: offline local storage
status: To Do
status: Done
assignee: []
created_date: '2025-12-03 23:42'
updated_date: '2025-12-04 12:13'
labels: []
updated_date: '2025-12-07 20:50'
labels:
- feature
- offline
- persistence
- indexeddb
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
IndexedDB persistence is already implemented via @automerge/automerge-repo-storage-indexeddb. The remaining work is:
1. Add real online/offline detection (currently always returns "online")
2. Create UI indicator showing connection status
3. Handle Safari's 7-day IndexedDB eviction
Existing code locations:
- src/automerge/useAutomergeSyncRepo.ts (lines 346, 380-432)
- src/automerge/useAutomergeStoreV2.ts (connectionStatus property)
- src/automerge/documentIdMapping.ts (room→document mapping)
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Real WebSocket connection state tracking (not hardcoded 'online')
- [x] #2 navigator.onLine integration for network detection
- [x] #3 UI indicator component showing connection status
- [x] #4 Visual feedback when working offline
- [x] #5 Auto-reconnect with status updates
- [ ] #6 Safari 7-day eviction mitigation (service worker or periodic touch)
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented connection status tracking:
- Added ConnectionState type and tracking in CloudflareAdapter
- Added navigator.onLine integration for network detection
- Exposed connectionState and isNetworkOnline from useAutomergeSync hook
- Created ConnectionStatusIndicator component with visual feedback
- Shows status only when not connected (connecting/reconnecting/disconnected/offline)
- Auto-hides when connected and online
Model files downloaded successfully: tiny.en-encoder.int8.onnx (13MB), tiny.en-decoder.int8.onnx (87MB), tokens.txt (816KB)
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,38 @@
---
id: task-004
title: IO Chip Feature
status: In Progress
assignee: []
created_date: '2025-12-03'
updated_date: '2025-12-07 06:43'
labels:
- feature
- io
- ui
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement IO chip feature for the canvas - enabling input/output connections between canvas elements.
## Branch Info
- **Branch**: `feature/io-chip`
- **Worktree**: `/home/jeffe/Github/canvas-website-io-chip`
- **Commit**: 527462a
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Create IO chip component
- [ ] #2 Enable connections between canvas elements
- [ ] #3 Handle data flow between connected chips
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Native Android app scaffolded and committed to main (0b1dac0). Dev branch created for future work.
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,42 @@
---
id: task-005
title: Automerge CRDT Sync
status: Done
assignee: []
created_date: '2025-12-03'
updated_date: '2025-12-05 03:41'
labels:
- feature
- sync
- collaboration
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement Automerge CRDT-based synchronization for real-time collaborative canvas editing.
## Branch Info
- **Branch**: `Automerge`
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Integrate Automerge library
- [ ] #2 Enable real-time sync between clients
- [ ] #3 Handle conflict resolution automatically
- [ ] #4 Persist state across sessions
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Binary Automerge sync implemented:
- CloudflareNetworkAdapter sends/receives binary sync messages
- Worker sends initial sync on connect
- Message buffering for early server messages
- documentId tracking for proper Automerge Repo routing
- Multi-client sync verified working
<!-- SECTION:NOTES:END -->

View File

@ -1,22 +0,0 @@
---
id: task-005
title: Automerge CRDT Sync
status: To Do
assignee: []
created_date: '2025-12-03'
labels: [feature, sync, collaboration]
priority: high
branch: Automerge
---
## Description
Implement Automerge CRDT-based synchronization for real-time collaborative canvas editing.
## Branch Info
- **Branch**: `Automerge`
## Acceptance Criteria
- [ ] Integrate Automerge library
- [ ] Enable real-time sync between clients
- [ ] Handle conflict resolution automatically
- [ ] Persist state across sessions

View File

@ -1,10 +1,10 @@
---
id: task-017
title: Deploy CryptID email recovery to dev branch and test
status: To Do
status: In Progress
assignee: []
created_date: '2025-12-04 12:00'
updated_date: '2025-12-04 12:27'
updated_date: '2025-12-11 15:15'
labels:
- feature
- cryptid

View File

@ -1,10 +1,10 @@
---
id: task-018
title: Create Cloudflare D1 cryptid-auth database
status: To Do
status: Done
assignee: []
created_date: '2025-12-04 12:02'
updated_date: '2025-12-04 12:27'
updated_date: '2025-12-06 06:39'
labels:
- infrastructure
- cloudflare
@ -108,4 +108,11 @@ git commit -m "chore: add D1 database IDs for cryptid-auth"
Feature branch: `feature/cryptid-email-recovery`
Code is ready - waiting for D1 database creation
Schema deployed to production D1 (35fbe755-0e7c-4b9a-a454-34f945e5f7cc)
Tables created:
- users, device_keys, verification_tokens (CryptID auth)
- boards, board_permissions (permissions system)
- user_profiles, user_connections, connection_metadata (social graph)
<!-- SECTION:NOTES:END -->

View File

@ -1,13 +1,19 @@
---
id: task-024
title: 'Open Mapping: Collaborative Route Planning Module'
status: To Do
status: Done
assignee: []
created_date: '2025-12-04 14:30'
updated_date: '2025-12-07 06:43'
labels:
- feature
- mapping
dependencies: []
dependencies:
- task-029
- task-030
- task-031
- task-036
- task-037
priority: high
---
@ -19,9 +25,9 @@ Implement an open-source mapping and routing layer for the canvas that provides
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 MapLibre GL JS integrated with tldraw canvas
- [ ] #2 OSRM routing backend deployed to Netcup
- [ ] #3 Waypoint placement and route calculation working
- [x] #1 MapLibre GL JS integrated with tldraw canvas
- [x] #2 OSRM routing backend deployed to Netcup
- [x] #3 Waypoint placement and route calculation working
- [ ] #4 Multi-route comparison UI implemented
- [ ] #5 Y.js collaboration for shared route editing
- [ ] #6 Layer management panel with basemap switching
@ -61,3 +67,118 @@ Phase 6 - Optimization:
- VROOM TSP/VRP
- Offline PWA
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
**Subsystem implementations completed:**
- task-029: zkGPS Privacy Protocol (src/open-mapping/privacy/)
- task-030: Mycelial Signal Propagation (src/open-mapping/mycelium/)
- task-031: Alternative Map Lens System (src/open-mapping/lenses/)
- task-036: Possibility Cones & Constraints (src/open-mapping/conics/)
- task-037: Location Games & Discovery (src/open-mapping/discovery/)
**Still needs:**
- MapLibre GL JS canvas integration
- OSRM backend deployment
- UI components for all subsystems
- Automerge sync for collaborative editing
Pushed to feature/open-mapping branch:
- MapShapeUtil for tldraw canvas integration
- Presence layer with location sharing
- Mycelium network visualization
- Discovery system (spores, hunts, collectibles)
- Privacy system with ZK-GPS protocol concepts
**Merged to dev branch (2025-12-05):**
- All subsystem TypeScript implementations merged
- MapShapeUtil integrated with canvas
- ConnectionStatusIndicator added
- Merged with PrivateWorkspace feature (no conflicts)
- Ready for staging/production testing
**Remaining work:**
- MapLibre GL JS full canvas integration
- OSRM backend deployment to Netcup
- UI polish and testing
**OSRM Backend Deployed (2025-12-05):**
- Docker container running on Netcup RS 8000
- Location: /opt/apps/osrm-routing/
- Public URL: https://routing.jeffemmett.com
- Uses Traefik for routing via Docker network
- Currently loaded with Monaco OSM data (for testing)
- MapShapeUtil updated to use self-hosted OSRM
- Verified working: curl returns valid route responses
Map refactoring completed:
- Created simplified MapShapeUtil.tsx (836 lines) with MapLibre + search + routing
- Created GPSCollaborationLayer.ts as standalone module for GPS sharing
- Added layers/index.ts and updated open-mapping exports
- Server running without compilation errors
- Architecture now follows layer pattern: Base Map → Collaboration Layers
Enhanced MapShapeUtil (1326 lines) with:
- Touch/pen/mouse support with proper z-index (1000+) and touchAction styles
- Search with autocomplete as you type (Nominatim, 400ms debounce)
- Directions panel with waypoint management, reverse route, clear
- GPS location sharing panel with start/stop, accuracy display
- Quick action toolbar: search, directions (🚗), GPS (📍), style picker
- Larger touch targets (44px buttons) for mobile
- Pulse animation on user GPS marker
- "Fit All" button to zoom to all GPS users
- Route info badge when panel is closed
Fixed persistence issue with two changes:
1. Server-side: handlePeerDisconnect now flushes pending saves immediately (prevents data loss on page close)
2. Client-side: Changed merge strategy from 'local takes precedence' to 'server takes precedence' for initial load
**D1 Database & Networking Fixes (2025-12-06):**
- Added CRYPTID_DB D1 binding to wrangler.dev.toml
- Applied schema.sql to local D1 database
- All 25 SQL commands executed successfully
- Networking API now working locally (returns 401 without auth as expected)
- Added d1_persist=true to miniflare config for data persistence
**CryptID Connections Feature:**
- Enhanced CustomToolbar.tsx with "People in Canvas" section
- Shows all tldraw collaborators with connection status colors
- Green border = trusted, Yellow = connected, Grey = unconnected
- Connect/Trust/Demote/Remove buttons for connection management
- Uses tldraw useValue hook for reactive collaborator updates
**Build Script Updates:**
- Added NODE_OPTIONS="--max-old-space-size=8192" to build, deploy, deploy:pages scripts
- Prevents memory issues during TypeScript compilation and Vite build
Completed Mapus-inspired MapShapeUtil enhancements:
- Left sidebar with title/description editing
- Search bar with Nominatim geocoding
- Find Nearby categories (8 types: Food, Drinks, Groceries, Hotels, Health, Services, Shopping, Transport)
- Collaborators list with Observe mode
- Annotations list with visibility toggle
- Drawing toolbar (cursor, marker, line, area, eraser)
- Color picker with 8 Mapus colors
- Style picker (Voyager, Light, Dark, Satellite)
- Zoom controls + GPS location button
- Fixed TypeScript errors (3 issues resolved)
**MapLibre Cleanup Fixes (2025-12-07):**
- Added isMountedRef to track component mount state
- Fixed map initialization cleanup with named event handlers
- Added try/catch blocks for all MapLibre operations
- Fixed style change, resize, and annotations effects with mounted checks
- Updated callbacks (observeUser, selectSearchResult, findNearby) with null checks
- Added legacy property support (interactive, showGPS, showSearch, showDirections, sharingLocation, gpsUsers)
- Prevents 'getLayer' and 'map' undefined errors during component unmount
- All schema validation errors resolved
**Feature Branch Created (2025-12-07):**
- Branch: feature/mapshapeutil-fixes
- Pushed to Gitea: https://gitea.jeffemmett.com/jeffemmett/canvas-website/compare/main...feature/mapshapeutil-fixes
- Includes all MapLibre cleanup fixes and z-index/pointer-event style improvements
- Ready for testing before merging to dev
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,105 @@
---
id: task-025
title: 'Google Export: Local-First Data Sovereignty'
status: Done
assignee: []
created_date: '2025-12-04 20:25'
updated_date: '2025-12-05 01:53'
labels:
- feature
- google
- encryption
- privacy
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Import Google Workspace data (Gmail, Drive, Photos, Calendar) locally, encrypt with WebCrypto, store in IndexedDB. User controls what gets shared to board or backed up to R2.
Worktree: /home/jeffe/Github/canvas-website-branch-worktrees/google-export
Branch: feature/google-export
Architecture docs in: docs/GOOGLE_DATA_SOVEREIGNTY.md
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 OAuth 2.0 with PKCE flow for Google APIs
- [x] #2 IndexedDB schema for encrypted data storage
- [x] #3 WebCrypto key derivation from master key
- [x] #4 Gmail import with pagination and progress
- [x] #5 Drive document import
- [x] #6 Photos thumbnail import
- [x] #7 Calendar event import
- [x] #8 Share to board functionality
- [x] #9 R2 encrypted backup/restore
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Starting implementation - reviewed architecture doc GOOGLE_DATA_SOVEREIGNTY.md
Implemented core Google Data Sovereignty module:
- types.ts: Type definitions for all encrypted data structures
- encryption.ts: WebCrypto AES-256-GCM encryption, HKDF key derivation, PKCE utilities
- database.ts: IndexedDB schema with stores for gmail, drive, photos, calendar, sync metadata, encryption metadata, tokens
- oauth.ts: OAuth 2.0 PKCE flow for Google APIs with encrypted token storage
- importers/gmail.ts: Gmail import with pagination, progress tracking, batch storage
- importers/drive.ts: Drive import with folder navigation, Google Docs export
- importers/photos.ts: Photos import with thumbnail caching, album support
- importers/calendar.ts: Calendar import with date range filtering, recurring events
- share.ts: Share service for creating tldraw shapes from encrypted data
- backup.ts: R2 backup service with encrypted manifest, checksum verification
- index.ts: Main module with GoogleDataService class and singleton pattern
TypeScript compilation passes - all core modules implemented
Committed and pushed to feature/google-export branch (e69ed0e)
All core modules implemented and working: OAuth, encryption, database, share, backup
Gmail, Drive, and Calendar importers working correctly
Photos importer has 403 error on some thumbnail URLs - needs investigation:
- May require proper OAuth consent screen verification
- baseUrl might need different approach for non-public photos
- Consider using Photos API mediaItems.get for base URLs instead of direct thumbnail access
Phase 2 complete: Renamed GoogleDataBrowser to GoogleExportBrowser (commit 33f5dc7)
Pushed to feature/google-export branch
Phase 3 complete: Added Private Workspace zone (commit 052c984)
- PrivateWorkspaceShapeUtil: Frosted glass container with pin/collapse/close
- usePrivateWorkspace hook for event handling
- PrivateWorkspaceManager component integrated into Board.tsx
Phase 4 complete: Added GoogleItemShape with privacy badges (commit 84c6bf8)
- GoogleItemShapeUtil: Visual distinction for local vs shared items
- Privacy badge with 🔒/🌐 icons
- Updated ShareableItem type with service and thumbnailUrl
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,37 @@
---
id: task-026
title: Fix text shape sync between clients
status: To Do
assignee: []
created_date: '2025-12-04 20:48'
labels:
- bug
- sync
- automerge
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Text shapes created with the "T" text tool show up on the creating client but not on other clients viewing the same board.
Root cause investigation:
- Text shapes ARE being persisted to R2 (confirmed in server logs)
- Issue is on receiving client side in AutomergeToTLStore.ts
- Line 1142: 'text' is in invalidTextProps list and gets deleted
- If richText isn't properly populated before text is deleted, content is lost
Files to investigate:
- src/automerge/AutomergeToTLStore.ts (sanitization logic)
- src/automerge/TLStoreToAutomerge.ts (serialization logic)
- src/automerge/useAutomergeStoreV2.ts (store updates)
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Text shapes sync correctly between multiple clients
- [ ] #2 Text content preserved during automerge serialization/deserialization
- [ ] #3 Both new and existing text shapes display correctly on all clients
<!-- AC:END -->

View File

@ -0,0 +1,90 @@
---
id: task-027
title: Implement proper Automerge CRDT sync for offline-first support
status: In Progress
assignee: []
created_date: '2025-12-04 21:06'
updated_date: '2025-12-06 06:55'
labels:
- offline-sync
- crdt
- automerge
- architecture
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace the current "last-write-wins" full document replacement with proper Automerge CRDT sync protocol. This ensures deletions are preserved across offline/reconnect scenarios and concurrent edits merge correctly.
Current problem: Server does `currentDoc.store = { ...newDoc.store }` which is full replacement, not merge. This causes "ghost resurrection" of deleted shapes when offline clients reconnect.
Solution: Use Automerge's native binary sync protocol with proper CRDT merge semantics.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Server stores Automerge binary documents in R2 (not JSON)
- [ ] #2 Client-server communication uses Automerge sync protocol (binary messages)
- [ ] #3 Deletions persist correctly when offline client reconnects
- [ ] #4 Concurrent edits merge deterministically without data loss
- [x] #5 Existing JSON rooms are migrated to Automerge format
- [ ] #6 All existing functionality continues to work
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
## Progress Update (2025-12-04)
### Implemented:
1. **automerge-init.ts** - WASM initialization for Cloudflare Workers using slim variant
2. **automerge-sync-manager.ts** - Core CRDT sync manager with proper merge semantics
3. **automerge-r2-storage.ts** - Binary R2 storage for Automerge documents
4. **wasm.d.ts** - TypeScript declarations for WASM imports
### Integration Fixes:
- `getDocument()` now returns CRDT document when sync manager is active
- `handleBinaryMessage()` syncs `currentDoc` with CRDT state after updates
- `schedulePersistToR2()` delegates to sync manager when CRDT mode is enabled
- Fixed CloudflareAdapter TypeScript errors (peer-candidate peerMetadata)
### Current State:
- `useCrdtSync = true` flag is enabled
- Worker compiles and runs successfully
- JSON sync fallback works for backward compatibility
- Binary sync infrastructure is in place
- Needs production testing with multi-client sync and delete operations
**Merged to dev branch (2025-12-05):**
- All Automerge CRDT infrastructure merged
- WASM initialization, sync manager, R2 storage
- Integration fixes for getDocument(), handleBinaryMessage(), schedulePersistToR2()
- Ready for production testing
### 2025-12-05: Data Safety Mitigations Added
Added safety mitigations for Automerge format conversion (commit f8092d8 on feature/google-export):
**Pre-conversion backups:**
- Before any format migration, raw document backed up to R2
- Location: `pre-conversion-backups/{roomId}/{timestamp}_{formatType}.json`
**Conversion threshold guards:**
- 10% loss threshold: Conversion aborts if too many records would be lost
- 5% shape loss warning: Emits warning if shapes are lost
**Unknown format handling:**
- Unknown formats backed up before creating empty document
- Raw document keys logged for investigation
**Also fixed:**
- Keyboard shortcuts dialog error (tldraw i18n objects)
- Google Workspace integration now first in Settings > Integrations
Fixed persistence issue: Modified handlePeerDisconnect to flush pending saves and updated client-side merge strategy in useAutomergeSyncRepo.ts to properly bootstrap from server when local is empty while preserving offline changes
Fixed TypeScript errors in networking module: corrected useSession->useAuth import, added myConnections to NetworkGraph type, fixed GraphEdge type alignment between client and worker
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,93 @@
---
id: task-028
title: OSM Canvas Integration Foundation
status: Done
assignee:
- '@claude'
created_date: '2025-12-04 21:12'
updated_date: '2025-12-04 21:44'
labels:
- feature
- mapping
- foundation
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement the foundational layer for rendering OpenStreetMap data on the tldraw canvas. This includes coordinate transformation (geographic ↔ canvas), tile rendering as canvas background, and basic interaction patterns.
Core components:
- Geographic coordinate system (lat/lng to canvas x/y transforms)
- OSM tile layer rendering (raster tiles as background)
- Zoom level handling that respects geographic scale
- Pan/zoom gestures that work with map context
- Basic marker/shape placement with geographic coordinates
- Vector tile support for interactive OSM elements
This is the foundation that task-024 (Route Planning) and other spatial features build upon.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 OSM raster tiles render as canvas background layer
- [x] #2 Coordinate transformation functions (geo ↔ canvas) working accurately
- [x] #3 Zoom levels map to appropriate tile zoom levels
- [x] #4 Pan/zoom gestures work smoothly with tile loading
- [x] #5 Shapes can be placed with lat/lng coordinates
- [x] #6 Basic MapLibre GL or Leaflet integration pattern established
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
## Progress (2025-12-04)
### Completed:
- Reviewed existing open-mapping module scaffolding
- Installed maplibre-gl npm package
- Created comprehensive geo-canvas coordinate transformation utilities (geoTransform.ts)
- GeoCanvasTransform class for bidirectional geo ↔ canvas transforms
- Web Mercator projection support
- Tile coordinate utilities
- Haversine distance calculations
### In Progress:
- Wiring up MapLibre GL JS in useMapInstance hook
- Creating MapShapeUtil for tldraw canvas integration
### Additional Progress:
- Fixed MapLibre attributionControl type issue
- Created MapShapeUtil.tsx with full tldraw integration
- Created MapTool.ts for placing map shapes
- Registered MapShape and MapTool in Board.tsx
- Map shape features:
- Resizable map window
- Interactive pan/zoom toggle
- Location presets (NYC, London, Tokyo, SF, Paris)
- Live coordinate display
- Pin to view support
- Tag system integration
### Completion Summary:
- All core OSM canvas integration foundation is complete
- MapShape can be placed on canvas via MapTool
- MapLibre GL JS renders OpenStreetMap tiles
- Coordinate transforms enable geo ↔ canvas mapping
- Ready for testing on dev server at localhost:5173
### Files Created/Modified:
- src/open-mapping/utils/geoTransform.ts (NEW)
- src/open-mapping/hooks/useMapInstance.ts (UPDATED with MapLibre)
- src/shapes/MapShapeUtil.tsx (NEW)
- src/tools/MapTool.ts (NEW)
- src/routes/Board.tsx (UPDATED with MapShape/MapTool)
- package.json (added maplibre-gl)
### Next Steps (task-024):
- Add OSRM routing backend
- Implement waypoint placement
- Route calculation and display
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,70 @@
---
id: task-029
title: zkGPS Protocol Design
status: Done
assignee:
- '@claude'
created_date: '2025-12-04 21:12'
updated_date: '2025-12-04 23:29'
labels:
- feature
- privacy
- cryptography
- research
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Design and implement a zero-knowledge proof system for privacy-preserving location sharing. Enables users to prove location claims without revealing exact coordinates.
Key capabilities:
- Proximity proofs: Prove "I am within X distance of Y" without revealing exact location
- Region membership: Prove "I am in Central Park" without revealing which part
- Temporal proofs: Prove "I was in region R between T1 and T2"
- Group rendezvous: N people prove they are all nearby without revealing locations to each other
Technical approaches to evaluate:
- ZK-SNARKs (Groth16, PLONK) for succinct proofs
- Bulletproofs for range proofs on coordinates
- Geohash commitments for variable precision
- Homomorphic encryption for distance calculations
- Ring signatures for group privacy
Integration with canvas:
- Share location with configurable precision per trust circle
- Verify location claims from network participants
- Display verified presence without exact coordinates
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Protocol specification document complete
- [x] #2 Proof-of-concept proximity proof working
- [x] #3 Geohash commitment scheme implemented
- [x] #4 Trust circle precision configuration UI
- [x] #5 Integration with canvas presence system
- [ ] #6 Performance benchmarks acceptable for real-time use
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Completed all zkGPS Protocol Design implementation:
- ZKGPS_PROTOCOL.md: Full specification document with design goals, proof types, wire protocol, security considerations
- geohash.ts: Complete geohash encoding/decoding with precision levels, neighbor finding, radius/polygon cell intersection
- types.ts: Comprehensive TypeScript types for commitments, trust circles, proofs, and protocol messages
- commitments.ts: Hash-based commitment scheme with salt, signing, and verification
- proofs.ts: Proximity, region, temporal, and group proximity proof generation/verification
- trustCircles.ts: TrustCircleManager class for managing social layer and precision-per-contact
- index.ts: Barrel export for clean module API
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,64 @@
---
id: task-030
title: Mycelial Signal Propagation System
status: Done
assignee:
- '@claude'
created_date: '2025-12-04 21:12'
updated_date: '2025-12-04 23:37'
labels:
- feature
- mapping
- intelligence
- research
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement a biologically-inspired signal propagation system for the canvas network, modeling how information, attention, and value flow through the collaborative space like nutrients through mycelium.
Core concepts:
- Nodes: Points of interest, events, people, resources, discoveries
- Hyphae: Connections/paths between nodes (relationships, routes, attention threads)
- Signals: Urgency, relevance, trust, novelty gradients
- Behaviors: Gradient following, path optimization, emergence detection
Features:
- Signal emission when events/discoveries occur
- Decay with spatial, relational, and temporal distance
- Aggregation at nodes (multiple weak signals → strong signal)
- Spore dispersal pattern for notifications
- Resonance detection (unconnected focus on same location)
- Collective blindspot visualization (unmapped areas)
The map becomes a living organism that breathes with activity cycles and grows where attention focuses.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Signal propagation algorithm implemented
- [x] #2 Decay functions configurable (spatial, relational, temporal)
- [x] #3 Visualization of signal gradients on canvas
- [x] #4 Resonance detection alerts working
- [x] #5 Spore-style notification system
- [x] #6 Blindspot/unknown area highlighting
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Completed Mycelial Signal Propagation System - 5 files in src/open-mapping/mycelium/:
types.ts: Node/Hypha/Signal/Decay/Propagation/Resonance type definitions with event system
signals.ts: Decay functions (exponential, linear, inverse, step, gaussian) + 4 propagation algorithms (flood, gradient, random-walk, diffusion)
network.ts: MyceliumNetwork class with node/hypha CRUD, signal emission/queue, resonance detection, maintenance loop, stats
visualization.ts: Color palettes, dynamic sizing, Canvas 2D rendering, heat maps, CSS keyframes
index.ts: Clean barrel export for entire module
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,65 @@
---
id: task-031
title: Alternative Map Lens System
status: Done
assignee:
- '@claude'
created_date: '2025-12-04 21:12'
updated_date: '2025-12-04 23:42'
labels:
- feature
- mapping
- visualization
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement multiple "lens" views that project different data dimensions onto the canvas coordinate space. The same underlying data can be viewed through different lenses.
Lens types:
- Geographic: Traditional OSM basemap, physical locations
- Temporal: Time as X-axis, events as nodes, time-scrubbing UI
- Attention: Heatmap of collective focus, nodes sized by current attention
- Incentive: Value gradients, token flows, MycoFi integration
- Relational: Social graph topology, force-directed layout
- Possibility: Branching futures, what-if scenarios, alternate timelines
Features:
- Smooth transitions between lens types
- Lens blending (e.g., 50% geographic + 50% attention)
- Temporal scrubber for historical playback
- Temporal portals (click location to see across time)
- Living maps that grow/fade based on attention
Each lens uses the same canvas shapes but transforms their positions and styling based on the active projection.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Lens switcher UI implemented
- [x] #2 Geographic lens working with OSM
- [x] #3 Temporal lens with time scrubber
- [x] #4 Attention heatmap visualization
- [x] #5 Smooth transitions between lenses
- [x] #6 Lens blending capability
- [ ] #7 Temporal portal feature (click to see history)
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Completed Alternative Map Lens System - 5 files in src/open-mapping/lenses/:
types.ts: All lens type definitions (Geographic, Temporal, Attention, Incentive, Relational, Possibility) with configs, transitions, events
transforms.ts: Coordinate transform functions for each lens type + force-directed layout algorithm for relational lens
blending.ts: Easing functions, transition creation/interpolation, point blending for multi-lens views
manager.ts: LensManager class with lens activation/deactivation, transitions, viewport control, temporal playback, temporal portals
index.ts: Clean barrel export for entire lens system
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,69 @@
---
id: task-032
title: Privacy Gradient Trust Circle System
status: To Do
assignee: []
created_date: '2025-12-04 21:12'
updated_date: '2025-12-05 01:42'
labels:
- feature
- privacy
- social
dependencies:
- task-029
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement a non-binary privacy system where location and presence information is shared at different precision levels based on trust circles.
Trust circle levels (configurable):
- Intimate: Exact coordinates, real-time updates
- Close: Street/block level precision
- Friends: Neighborhood/district level
- Network: City/region only
- Public: Just "online" status or timezone
Features:
- Per-contact trust level configuration
- Group trust levels (share more with "coworkers" group)
- Automatic precision degradation over time
- Selective disclosure controls per-session
- Trust level visualization on map (concentric circles of precision)
- Integration with zkGPS for cryptographic enforcement
- Consent management and audit logs
The system should default to maximum privacy and require explicit opt-in to share more precise information.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Trust circle configuration UI
- [ ] #2 Per-contact precision settings
- [x] #3 Group-based trust levels
- [x] #4 Precision degradation over time working
- [ ] #5 Visual representation of trust circles on map
- [ ] #6 Consent management interface
- [x] #7 Integration points with zkGPS task
- [x] #8 Privacy-by-default enforced
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
**TypeScript foundation completed in task-029:**
- TrustCircleManager class (src/open-mapping/privacy/trustCircles.ts)
- 5 trust levels with precision mapping
- Per-contact trust configuration
- Group trust levels
- Precision degradation over time
- Integration with zkGPS commitments
**Still needs UI components:**
- Trust circle configuration panel
- Contact management interface
- Visual concentric circles on map
- Consent management dialog
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,87 @@
---
id: task-033
title: Version History & Reversion System with Visual Diffs
status: Done
assignee: []
created_date: '2025-12-04 21:44'
updated_date: '2025-12-05 00:46'
labels:
- feature
- version-control
- automerge
- r2
- ui
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement a comprehensive version history and reversion system that allows users to:
1. View and revert to historical board states
2. See visual diffs highlighting new/deleted shapes since their last visit
3. Walk through CRDT history step-by-step
4. Restore accidentally deleted shapes
Key features:
- Time rewind button next to the star dashboard button
- Popup menu showing historical versions
- Yellow glow on newly added shapes (first time user sees them)
- Dim grey on deleted shapes with "undo discard" option
- Permission-based (admin, editor, viewer)
- Integration with R2 backups and Automerge CRDT history
- Compare user's local state with server state to highlight diffs
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Version history button renders next to star button with time-rewind icon
- [x] #2 Clicking button opens popup showing list of historical versions
- [x] #3 User can select a version to preview or revert to
- [x] #4 Newly added shapes since last user visit have yellow glow effect
- [x] #5 Deleted shapes show dimmed with 'undo discard' option
- [x] #6 Version navigation respects user permissions (admin/editor/viewer)
- [x] #7 Works with R2 backup snapshots for coarse-grained history
- [ ] #8 Leverages Automerge CRDT for fine-grained change tracking
- [x] #9 User's last-seen state stored in localStorage for diff comparison
- [x] #10 Visual effects are subtle and non-intrusive
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implementation complete in feature/version-reversion worktree:
**Files Created:**
- src/lib/versionHistory.ts - Core version history utilities
- src/lib/permissions.ts - Role-based permission system
- src/components/VersionHistoryButton.tsx - Time-rewind icon button
- src/components/VersionHistoryPanel.tsx - Panel with 3 tabs
- src/components/DeletedShapesOverlay.tsx - Floating deleted shapes indicator
- src/hooks/useVersionHistory.ts - React hook for state management
- src/hooks/usePermissions.ts - Permission context hook
- src/css/version-history.css - Visual effects CSS
**Files Modified:**
- src/ui/CustomToolbar.tsx - Added VersionHistoryButton
- src/ui/components.tsx - Added DeletedShapesOverlay
- src/css/style.css - Imported version-history.css
- worker/worker.ts - Added /api/versions endpoints
**Features Implemented:**
1. Time-rewind button next to star dashboard
2. Version History Panel with Changes/Versions/Deleted tabs
3. localStorage tracking of user's last-seen state
4. Yellow glow animation for new shapes
5. Dim grey effect for deleted shapes
6. Floating indicator with restore options
7. R2 integration for version snapshots
8. Permission system (admin/editor/viewer roles)
Commit: 03894d2
Renamed GoogleDataBrowser to GoogleExportBrowser as requested by user
Pushed to feature/google-export branch (commit 33f5dc7)
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,42 @@
---
id: task-034
title: Fix Google Photos 403 error on thumbnail URLs
status: To Do
assignee: []
created_date: '2025-12-04 23:24'
labels:
- bug
- google
- photos
dependencies:
- task-025
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Debug and fix the 403 Forbidden errors when fetching Google Photos thumbnails in the Google Data Sovereignty module.
Current behavior:
- Photos metadata imports successfully
- Thumbnail URLs (baseUrl with =w200-h200 suffix) return 403
- Error occurs even with valid OAuth token
Investigation areas:
1. OAuth consent screen verification status (test mode vs published)
2. Photo sharing status (private vs shared photos may behave differently)
3. baseUrl expiration - Google Photos baseUrls expire after ~1 hour
4. May need to use mediaItems.get API to refresh baseUrl before each fetch
5. Consider adding Authorization header to thumbnail fetch requests
Reference: src/lib/google/importers/photos.ts in feature/google-export branch
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Photos thumbnails download without 403 errors
- [ ] #2 OAuth consent screen properly configured if needed
- [ ] #3 baseUrl refresh mechanism implemented if required
- [ ] #4 Test with both private and shared photos
<!-- AC:END -->

View File

@ -0,0 +1,90 @@
---
id: task-035
title: 'Data Sovereignty Zone: Private Workspace UI'
status: Done
assignee: []
created_date: '2025-12-04 23:36'
updated_date: '2025-12-05 02:00'
labels:
- feature
- privacy
- google
- ui
dependencies:
- task-025
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement privacy-first UX for managing LOCAL (encrypted IndexedDB) vs SHARED (collaborative) data on the canvas.
Key features:
- Google Integration card in Settings modal
- Data Browser popup for selecting encrypted items
- Private Workspace zone (toggleable, frosted glass container)
- Visual distinction: 🔒 shaded overlay for local, normal for shared
- Permission prompt when dragging items outside workspace
Design decisions:
- Toggleable workspace that can pin to viewport
- Items always start private, explicit share action required
- ZK integration deferred to future phase
- R2 upload visual-only for now
Worktree: /home/jeffe/Github/canvas-website-branch-worktrees/google-export
Branch: feature/google-export
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Google Workspace integration card in Settings Integrations tab
- [x] #2 Data Browser popup with service tabs and item selection
- [x] #3 Private Workspace zone shape with frosted glass effect
- [x] #4 Privacy badges (lock/globe) on items showing visibility
- [x] #5 Permission modal when changing visibility from local to shared
- [ ] #6 Zone can be toggled visible/hidden and pinned to viewport
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Phase 1 complete (c9c8c00):
- Added Google Workspace section to Settings > Integrations tab
- Connection status badge and import counts display
- Connect/Disconnect buttons with loading states
- Added getStoredCounts() method to GoogleDataService
- Privacy messaging about AES-256 encryption
Phase 2 complete (a754ffa):
- GoogleDataBrowser component with service tabs
- Searchable, multi-select item list
- Dark mode support
- Privacy messaging and 'Add to Private Workspace' action
Phase 5 completed: Implemented permission flow and drag detection
Created VisibilityChangeModal.tsx for confirming visibility changes
Created VisibilityChangeManager.tsx to handle events and drag detection
GoogleItem shapes dispatch visibility change events on badge click
Support both local->shared and shared->local transitions
Auto-detect when GoogleItems are dragged outside PrivateWorkspace
Session storage for 'don't ask again' preference
All 5 phases complete - full data sovereignty UI implementation done
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,35 @@
---
id: task-036
title: Implement Possibility Cones and Constraint Propagation System
status: Done
assignee: []
created_date: '2025-12-05 00:45'
labels:
- feature
- open-mapping
- visualization
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implemented a mathematical framework for visualizing how constraints propagate through decision pipelines. Each decision point creates a "possibility cone" - a light-cone-like structure representing reachable futures. Subsequent constraints act as apertures that narrow these cones.
Key components:
- types.ts: Core type definitions (SpacePoint, PossibilityCone, ConeConstraint, ConeIntersection, etc.)
- geometry.ts: Vector operations, cone math, conic sections, intersection algorithms
- pipeline.ts: ConstraintPipelineManager for constraint propagation through stages
- optimization.ts: PathOptimizer with A*, Dijkstra, gradient descent, simulated annealing
- visualization.ts: Rendering helpers for 2D/3D projections, SVG paths, canvas rendering
Features:
- N-dimensional possibility space with configurable dimensions
- Constraint pipeline with stages and dependency analysis
- Multiple constraint surface types (hyperplane, sphere, cone, custom)
- Value-weighted path optimization through constrained space
- Waist detection (bottleneck finding)
- Caustic point detection (convergence analysis)
- Animation helpers for cone narrowing visualization
<!-- SECTION:DESCRIPTION:END -->

View File

@ -0,0 +1,114 @@
---
id: task-037
title: zkGPS Location Games and Discovery System
status: In Progress
assignee: []
created_date: '2025-12-05 00:49'
updated_date: '2025-12-05 03:52'
labels:
- feature
- open-mapping
- games
- zkGPS
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Build a location-based game framework combining zkGPS privacy proofs with collaborative mapping for treasure hunts, collectibles, and IoT-anchored discoveries.
Use cases:
- Conference treasure hunts with provable location without disclosure
- Collectible elements anchored to physical locations
- Crafting/combining discovered items
- Mycelial network growth between discovered nodes
- IoT hardware integration (NFC tags, BLE beacons)
Game mechanics:
- Proximity proofs ("I'm within 50m of X" without revealing where)
- Hot/cold navigation using geohash precision degradation
- First-finder rewards with timestamp proofs
- Group discovery requiring N players in proximity
- Spore collection and mycelium cultivation
- Fruiting bodies when networks connect
Integration points:
- zkGPS commitments for hidden locations
- Mycelium network for discovery propagation
- Trust circles for team-based play
- Possibility cones for "reachable discoveries" visualization
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Discovery anchor types (physical, virtual, IoT)
- [x] #2 Proximity proof verification for discoveries
- [x] #3 Collectible item system with crafting
- [x] #4 Mycelium growth between discovered locations
- [x] #5 Team/group discovery mechanics
- [x] #6 Hot/cold navigation hints
- [x] #7 First-finder and timestamp proofs
- [x] #8 IoT anchor protocol (NFC/BLE/QR)
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented complete discovery game system with:
**types.ts** - Comprehensive type definitions:
- Discovery anchors (physical, NFC, BLE, QR, virtual, temporal, social)
- IoT requirements and social requirements
- Collectibles, crafting recipes, inventory slots
- Spores, planted spores, fruiting bodies
- Treasure hunts, scoring, leaderboards
- Hot/cold navigation hints
**anchors.ts** - Anchor management:
- Create anchors with zkGPS commitments
- Proximity-based discovery verification
- Hot/cold navigation hints
- Prerequisite and cooldown checking
- IoT and social requirement verification
**collectibles.ts** - Item and crafting system:
- ItemRegistry for item definitions
- InventoryManager with stacking
- CraftingManager with recipes
- Default spore, fragment, and artifact items
**spores.ts** - Mycelium integration:
- 7 spore types (explorer, connector, amplifier, guardian, harvester, temporal, social)
- Planting spores at discovered locations
- Hypha connections between nearby spores
- Fruiting body emergence when networks connect
- Growth simulation with nutrient decay
**hunts.ts** - Treasure hunt management:
- Create hunts with multiple anchors
- Sequential or free-form discovery
- Scoring with bonuses (first finder, time, sequence, group)
- Leaderboards and prizes
- Hunt templates (quick, standard, epic, team)
Moving to In Progress - core TypeScript implementation complete, still needs:
- UI components for discovery/hunt interfaces
- Canvas integration for map visualization
- Real IoT hardware testing (NFC/BLE)
- Backend persistence layer
- Multiplayer sync via Automerge
**Merged to dev branch (2025-12-05):**
- Complete discovery game system TypeScript merged
- Anchor, collectible, spore, and hunt systems in place
- All type definitions and core logic implemented
**Still needs for production:**
- React UI components for discovery/hunt interfaces
- Canvas map visualization integration
- IoT hardware testing (NFC/BLE)
- Backend persistence layer
- Multiplayer sync testing
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,59 @@
---
id: task-038
title: Real-Time Location Presence with Privacy Controls
status: Done
assignee: []
created_date: '2025-12-05 02:00'
updated_date: '2025-12-05 02:00'
labels:
- feature
- open-mapping
- privacy
- collaboration
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implemented real-time location sharing with trust-based privacy controls for collaborative mapping.
Key features:
- Privacy-preserving location via zkGPS commitments
- Trust circle precision controls (intimate ~2.4m → public ~630km)
- Real-time broadcasting and receiving of presence
- Proximity detection without revealing exact location
- React hook for easy canvas integration
- Map visualization components (PresenceLayer, PresenceList)
Files created in src/open-mapping/presence/:
- types.ts: Comprehensive type definitions
- manager.ts: PresenceManager class with location watch, broadcasting, trust circles
- useLocationPresence.ts: React hook for canvas integration
- PresenceLayer.tsx: Map visualization components
- index.ts: Barrel export
Integration pattern:
```typescript
const presence = useLocationPresence({
channelId: 'room-id',
user: { pubKey, privKey, displayName, color },
broadcastFn: (data) => automergeAdapter.broadcast(data),
});
// Set trust levels for contacts
presence.setTrustLevel(bobKey, 'friends'); // ~2.4km precision
presence.setTrustLevel(aliceKey, 'intimate'); // ~2.4m precision
```
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Location presence types defined
- [x] #2 PresenceManager with broadcasting
- [x] #3 Trust-based precision controls
- [x] #4 React hook for canvas integration
- [x] #5 Map visualization components
- [x] #6 Proximity detection without exact location
<!-- AC:END -->

View File

@ -0,0 +1,154 @@
---
id: task-039
title: 'MapShape Integration: Connect Subsystems to Canvas Shape'
status: Done
assignee: []
created_date: '2025-12-05 02:12'
updated_date: '2025-12-05 03:41'
labels:
- feature
- mapping
- integration
dependencies:
- task-024
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Evolve MapShapeUtil.tsx to integrate the 6 implemented subsystems (privacy, mycelium, lenses, conics, discovery, presence) into the canvas map shape. Currently the MapShape is a standalone map viewer - it needs to become the central hub for all open-mapping features.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 MapShape props extended for subsystem toggles
- [x] #2 Presence layer integrated with opt-in location sharing
- [x] #3 Lens system accessible via UI
- [x] #4 Route/waypoint visualization working
- [x] #5 Collaboration sync via Automerge
- [x] #6 Discovery game elements visible on map
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
**MapShape Evolution Progress (Dec 5, 2025):**
### Completed:
1. **Extended IMapShape Props** - Added comprehensive subsystem configuration types:
- `MapPresenceConfig` - Location sharing with privacy levels
- `MapLensConfig` - Alternative map projections
- `MapDiscoveryConfig` - Games, anchors, spores, hunts
- `MapRoutingConfig` - Waypoints, routes, alternatives
- `MapConicsConfig` - Possibility cones visualization
2. **Header UI Controls** - Subsystem toolbar with:
- ⚙️ Expandable subsystem panel
- Toggle buttons for each subsystem
- Lens selector dropdown (6 lens types)
- Share location button for presence
- Active subsystem indicators in header
3. **Visualization Layers Added:**
- Route polyline layer (MapLibre GeoJSON source/layer)
- Waypoint markers management
- Routing panel (bottom-right) with stats
- Presence panel (bottom-left) with share button
- Discovery panel (top-right) with checkboxes
- Lens indicator badge (top-left when active)
### Still Needed:
- Actual MapLibre marker implementation for waypoints
- Integration with OSRM routing backend
- Connect presence system to actual location services
- Wire up discovery system to anchor/spore data
**Additional Implementation (Dec 5, 2025):**
### Routing System - Fully Working:
- ✅ MapLibre.Marker implementation with draggable waypoints
- ✅ Click-to-add-waypoint when routing enabled
- ✅ OSRM routing service integration (public server)
- ✅ Auto-route calculation after adding/dragging waypoints
- ✅ Route polyline rendering with GeoJSON layer
- ✅ Clear route button with full state reset
- ✅ Loading indicator during route calculation
- ✅ Distance/duration display in routing panel
### Presence System - Fully Working:
- ✅ Browser Geolocation API integration
- ✅ Location watching with configurable accuracy
- ✅ User location marker with pulsing animation
- ✅ Error handling (permission denied, unavailable, timeout)
- ✅ "Go to My Location" button with flyTo animation
- ✅ Privacy level affects GPS accuracy settings
- ✅ Real-time coordinate display when sharing
### Still TODO:
- Discovery system anchor visualization
- Automerge sync for collaborative editing
Phase 5: Automerge Sync Integration - Analyzing existing sync architecture. TLDraw shapes sync automatically via TLStoreToAutomerge.ts. MapShape props should already sync since they're part of the shape record.
**Automerge Sync Implementation Complete (Dec 5, 2025):**
1. **Collaborative sharedLocations** - Added `sharedLocations: Record<string, SharedLocation>` to MapPresenceConfig props
2. **Conflict-free updates** - Each user updates only their own key in sharedLocations, allowing Automerge CRDT to handle concurrent updates automatically
3. **Location sync effect** - When user shares location, their coordinate is published to sharedLocations with userId, userName, color, timestamp, and privacyLevel
4. **Auto-cleanup** - User's entry is removed from sharedLocations when they stop sharing
5. **Collaborator markers** - Renders MapLibre markers for all other users' shared locations (different from user's own pulsing marker)
6. **Stale location filtering** - Collaborator locations older than 5 minutes are not rendered
7. **UI updates** - Presence panel now shows count of online collaborators
**How it works:**
- MapShape props sync automatically via existing TLDraw → Automerge infrastructure
- When user calls editor.updateShape() to update MapShape props, changes flow through TLStoreToAutomerge.ts
- Remote changes come back via Automerge patches and update the shape's props
- Each user only writes to their own key in sharedLocations, so no conflicts occur
**Discovery Visualization Complete (Dec 5, 2025):**
### Added Display Types for Automerge Sync:
- `DiscoveryAnchorMarker` - Simplified anchor data for map markers
- `SporeMarker` - Mycelium spore data with strength and connections
- `HuntMarker` - Treasure hunt waypoints with sequence numbers
### MapDiscoveryConfig Extended:
- `anchors: DiscoveryAnchorMarker[]` - Synced anchor data
- `spores: SporeMarker[]` - Synced spore data with connection graph
- `hunts: HuntMarker[]` - Synced treasure hunt waypoints
### Marker Rendering Implemented:
1. **Anchor Markers** - Circular markers with type-specific colors (physical=green, nfc=blue, qr=purple, virtual=amber). Hidden anchors shown with reduced opacity until discovered.
2. **Spore Markers** - Pulsing circular markers with radial gradients. Size scales with spore strength (40-100%). Animation keyframes for organic feel.
3. **Mycelium Network** - GeoJSON LineString layer connecting spores. Dashed green lines with 60% opacity visualize the network connections.
4. **Hunt Markers** - Numbered square markers for treasure hunts. Amber when not found, green with checkmark when discovered.
### Discovery Panel Enhanced:
- Stats display showing counts: 📍 anchors, 🍄 spores, 🏆 hunts
- "+Add Anchor" button - Creates demo anchor at map center
- "+Add Spore" button - Creates demo spore with random connection
- "+Add Hunt Point" button - Creates treasure hunt waypoint
- "Clear All" button - Removes all discovery elements
### How Automerge Sync Works:
- Discovery data stored in MapShape.props.discovery
- Shape updates via editor.updateShape() flow through TLStoreToAutomerge
- All collaborators see markers appear in real-time
- Each user can add/modify elements, CRDT handles conflicts
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,39 @@
---
id: task-040
title: 'Open-Mapping Production Ready: Fix TypeScript, Enable Build, Polish UI'
status: In Progress
assignee: []
created_date: '2025-12-05 21:58'
labels:
- feature
- mapping
- typescript
- build
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Make the open-mapping module production-ready by fixing TypeScript errors, re-enabling it in the build, and polishing the UI components.
Currently the open-mapping directory is excluded from tsconfig due to TypeScript errors. This task covers:
1. Fix TypeScript errors in src/open-mapping/**
2. Re-enable in tsconfig.json
3. Add NODE_OPTIONS for build memory
4. Polish MapShapeUtil UI (multi-route, layer panel)
5. Test collaboration features
6. Deploy to staging
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 open-mapping included in tsconfig without errors
- [ ] #2 npm run build succeeds
- [ ] #3 MapShapeUtil renders and functions correctly
- [ ] #4 Routing via OSRM works
- [ ] #5 GPS sharing works between clients
- [ ] #6 Layer switching works
- [ ] #7 Search with autocomplete works
<!-- AC:END -->

View File

@ -0,0 +1,91 @@
---
id: task-041
title: User Networking & Social Graph Visualization
status: Done
assignee: []
created_date: '2025-12-06 06:17'
updated_date: '2025-12-06 06:46'
labels:
- feature
- social
- visualization
- networking
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Build a social networking layer on the canvas that allows users to:
1. Tag other users as "connected" to them
2. Search by username to add connections
3. Track connected network of CryptIDs
4. Replace top-right presence icons with bottom-right graph visualization
5. Create 3D interactive graph at graph.jeffemmett.com
Key Components:
- Connection storage (extend trust circles in D1/Automerge)
- User search API
- 2D mini-graph in bottom-right (like minimap)
- 3D force-graph visualization (Three.js/react-force-graph-3d)
- Edge metadata (relationship types, clickable edges)
Architecture: Extends existing presence system in open-mapping/presence/ and trust circles in privacy/trustCircles.ts
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Users can search and add connections to other CryptIDs
- [x] #2 Connections persist across sessions in D1 database
- [x] #3 Bottom-right graph visualization shows room users and connections
- [ ] #4 3D graph at graph.jeffemmett.com is interactive (spin, zoom, click)
- [ ] #5 Clicking edges allows defining relationship metadata
- [x] #6 Real-time updates when connections change
- [x] #7 Privacy-respecting (honors trust circle permissions)
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Design decisions made:
- Binary connections only: 'connected' or 'not connected'
- All usernames publicly searchable
- One-way following allowed (no acceptance required)
- Graph scope: full network in grey, room participants colored by presence
- Edge metadata private to the two connected parties
Implementation complete:
**Files Created:**
- worker/schema.sql: Added user_profiles, user_connections, connection_metadata tables
- worker/types.ts: Added TrustLevel, UserConnection, GraphEdge, NetworkGraph types
- worker/networkingApi.ts: Full API implementation for connections, search, graph
- src/lib/networking/types.ts: Client-side types with trust levels
- src/lib/networking/connectionService.ts: API client
- src/lib/networking/index.ts: Module exports
- src/components/networking/useNetworkGraph.ts: React hook for graph state
- src/components/networking/UserSearchModal.tsx: User search UI
- src/components/networking/NetworkGraphMinimap.tsx: 2D force graph with d3
- src/components/networking/NetworkGraphPanel.tsx: Tldraw integration wrapper
- src/components/networking/index.ts: Component exports
**Modified Files:**
- worker/worker.ts: Added networking API routes
- src/ui/components.tsx: Added NetworkGraphPanel to InFrontOfCanvas
**Trust Levels:**
- unconnected (grey): No permissions
- connected (yellow): View permission
- trusted (green): Edit permission
**Features:**
- One-way following (no acceptance required)
- Trust level upgrade/downgrade
- Edge metadata (private labels, notes, colors)
- Room participants highlighted with presence colors
- Full network shown in grey, room subset colored
- Expandable to 3D view (future: graph.jeffemmett.com)
2D implementation complete. Follow-up task-042 created for 3D graph and edge metadata editor modal.
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,52 @@
---
id: task-042
title: 3D Network Graph Visualization & Edge Metadata Editor
status: To Do
assignee: []
created_date: '2025-12-06 06:46'
labels:
- feature
- visualization
- 3d
- networking
dependencies:
- task-041
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Build the 3D interactive network visualization at graph.jeffemmett.com and implement the edge metadata editor modal. This extends the 2D minimap created in task-041.
Key Features:
1. **3D Force Graph** at graph.jeffemmett.com
- Three.js / react-force-graph-3d visualization
- Full-screen, interactive (spin, zoom, pan)
- Click nodes to view user profiles
- Click edges to edit metadata
- Same trust level coloring (grey/yellow/green)
- Real-time presence sync with canvas rooms
2. **Edge Metadata Editor Modal**
- Opens on edge click in 2D minimap or 3D view
- Edit: label, notes, color, strength (1-10)
- Private to each party on the edge
- Bidirectional - each user has their own metadata view
3. **Expand Button Integration**
- 2D minimap expand button opens 3D view
- URL sharing for specific graph views
- Optional: embed 3D graph back in canvas as iframe
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 3D force graph at graph.jeffemmett.com renders user network
- [ ] #2 Graph is interactive: spin, zoom, pan, click nodes/edges
- [ ] #3 Edge metadata editor modal allows editing label, notes, color, strength
- [ ] #4 Edge metadata persists to D1 and is private per-user
- [ ] #5 Expand button in 2D minimap opens 3D view
- [ ] #6 Real-time updates when connections change
- [ ] #7 Trust level colors match 2D minimap (grey/yellow/green)
<!-- AC:END -->

View File

@ -0,0 +1,79 @@
---
id: task-042
title: User Permissions - View, Edit, Admin Levels
status: In Progress
assignee: [@claude]
created_date: '2025-12-05 14:00'
updated_date: '2025-12-05 14:00'
labels:
- feature
- auth
- permissions
- cryptid
- security
dependencies:
- task-018
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement a three-tier permission system for canvas boards:
**Permission Levels:**
1. **View** - Can see board contents, cannot edit. Default for anonymous/unauthenticated users.
2. **Edit** - Can see and modify board contents. Requires CryptID authentication.
3. **Admin** - Full access + can manage board settings and user permissions. Board owner by default.
**Key Features:**
- Anonymous users can view any shared board but cannot edit
- Creating a CryptID (username only, no password) grants edit access
- CryptID uses WebCrypto API for browser-based cryptographic keys (W3C standard)
- Session state encrypted and stored offline for authenticated users
- Admins can invite users with specific permission levels
**Anonymous User Banner:**
Display a banner for unauthenticated users:
> "If you want to edit this board, just sign in by creating a username as your CryptID - no password required! Your CryptID is secured with encrypted keys, right in your browser, by a W3C standard algorithm. As a bonus, your session will be stored for offline access, encrypted in your browser storage by the same key, allowing you to use it securely any time you like, with full data portability."
**Technical Foundation:**
- Builds on existing CryptID WebCrypto authentication (`auth-webcrypto` branch)
- Extends D1 database schema for board-level permissions
- Read-only mode in tldraw editor for view-only users
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Anonymous users can view any shared board content
- [ ] #2 Anonymous users cannot create, edit, or delete shapes
- [ ] #3 Anonymous users see a dismissible banner prompting CryptID sign-up
- [ ] #4 Creating a CryptID grants immediate edit access to current board
- [ ] #5 Board creator automatically becomes admin
- [ ] #6 Admins can view and manage board permissions
- [ ] #7 Permission levels enforced on both client and server (worker)
- [ ] #8 Authenticated user sessions stored encrypted in browser storage
- [ ] #9 Read-only toolbar/UI state for view-only users
- [ ] #10 Permission state syncs correctly across devices via CryptID
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
**Branch:** `feature/user-permissions`
**Completed:**
- [x] Database schema for boards and board_permissions tables
- [x] Permission types (PermissionLevel) in worker and client
- [x] Permission API handlers (boardPermissions.ts)
- [x] AuthContext updated with permission fetching/caching
- [x] AnonymousViewerBanner component with CryptID signup
**In Progress:**
- [ ] Board component read-only mode integration
- [ ] Automerge sync permission checking
**Dependencies:**
- `task-018` - D1 database creation (blocking for production)
- `auth-webcrypto` branch - WebCrypto authentication (merged)
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,38 @@
---
id: task-043
title: Build and publish Voice Command Android APK
status: To Do
assignee: []
created_date: '2025-12-07 06:31'
labels:
- android
- voice-command
- mobile
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Native Android app for voice-to-text transcription with on-device Whisper processing has been scaffolded. Next steps:
1. Download Whisper model files (run download-models.sh)
2. Set up Android signing keystore
3. Build debug APK and test on device
4. Fix any runtime issues
5. Build release APK
6. Publish to GitHub releases
The app uses sherpa-onnx for on-device transcription, supports floating button, volume button triggers, and Quick Settings tile.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Model files downloaded and bundled
- [ ] #2 APK builds successfully
- [ ] #3 Recording works on real device
- [ ] #4 Transcription produces accurate results
- [ ] #5 All trigger methods functional
- [ ] #6 Release APK signed and published
<!-- AC:END -->

View File

@ -0,0 +1,39 @@
---
id: task-044
title: Test dev branch UI redesign and Map fixes
status: Done
assignee: []
created_date: '2025-12-07 23:26'
updated_date: '2025-12-08 01:19'
labels: []
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Test the changes pushed to dev branch in commit 8123f0f
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 CryptID dropdown works (sign in/out, Google integration)
- [ ] #2 Settings gear dropdown shows dark mode toggle
- [ ] #3 Social Network graph shows user as lone node when solo
- [ ] #4 Map marker tool adds markers on click
- [ ] #5 Map scroll wheel zooms correctly
- [ ] #6 Old boards with Map shapes load without validation errors
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Session completed. All changes pushed to dev branch:
- UI redesign: unified top-right menu with grey oval container
- Social Network graph: dark theme with directional arrows
- MI bar: responsive layout (bottom on mobile)
- Map fixes: tool clicks work, scroll zoom works
- Automerge: Map shape schema validation fix
- Network graph: graceful fallback on API errors
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,19 @@
---
id: task-045
title: Implement offline-first loading from IndexedDB
status: Done
assignee: []
created_date: '2025-12-08 08:47'
labels:
- bug-fix
- offline
- automerge
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fixed a bug where the app would hang indefinitely when the server wasn't running because `await adapter.whenReady()` blocked IndexedDB loading. Now the app loads from IndexedDB first (offline-first), then syncs with server in the background with a 5-second timeout.
<!-- SECTION:DESCRIPTION:END -->

View File

@ -0,0 +1,26 @@
---
id: task-046
title: Add maximize button to StandardizedToolWrapper
status: Done
assignee: []
created_date: '2025-12-08 08:51'
updated_date: '2025-12-08 09:03'
labels:
- feature
- ui
- shapes
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Added a maximize/fullscreen button to the standardized header bar. When clicked, the tool fills the viewport. Press Esc or click again to restore original dimensions. Created useMaximize hook that shape utils can use. Implemented on ChatBoxShapeUtil as example.
<!-- SECTION:DESCRIPTION:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added maximize to ALL 16 shapes using StandardizedToolWrapper (not just ChatBox)
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,49 @@
---
id: task-047
title: Improve mobile touch/pen interactions across custom tools
status: Done
assignee: []
created_date: '2025-12-10 18:28'
updated_date: '2025-12-10 18:28'
labels:
- mobile
- touch
- ux
- accessibility
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fixed touch and pen interaction issues across all custom canvas tools to ensure they work properly on mobile devices and with stylus input.
Changes made:
- Added onTouchStart/onTouchEnd handlers to all interactive elements
- Added touchAction: 'manipulation' CSS to prevent 300ms click delay
- Increased minimum touch target sizes to 44px for accessibility
- Fixed ImageGen: Generate button, Copy/Download/Delete, input field
- Fixed VideoGen: Upload, URL input, prompt, duration, Generate button
- Fixed Transcription: Start/Stop/Pause buttons, textarea, Save/Cancel
- Fixed Multmux: Create Session, Refresh, session list, input fields
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 All buttons respond to touch on mobile devices
- [x] #2 No 300ms click delay on interactive elements
- [x] #3 Touch targets are at least 44px for accessibility
- [x] #4 Image generation works on mobile
- [x] #5 Video generation works on mobile
- [x] #6 Transcription controls work on mobile
- [x] #7 Terminal (Multmux) controls work on mobile
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Pushed to dev branch: b6af3ec
Files modified: ImageGenShapeUtil.tsx, VideoGenShapeUtil.tsx, TranscriptionShapeUtil.tsx, MultmuxShapeUtil.tsx
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,58 @@
---
id: task-048
title: Version History & CryptID Registration Enhancements
status: Done
assignee: []
created_date: '2025-12-10 22:22'
updated_date: '2025-12-10 22:22'
labels:
- feature
- auth
- history
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add version history feature with diff visualization and enhance CryptID registration flow with email backup
<!-- SECTION:DESCRIPTION:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
## Implementation Summary
### Email Service (SendGrid → Resend)
- Updated `worker/types.ts` to use `RESEND_API_KEY`
- Updated `worker/cryptidAuth.ts` sendEmail() to use Resend API
### CryptID Registration Flow
- Multi-step registration: welcome → username → email → success
- Detailed explainer about passwordless authentication
- Email backup for multi-device access
- Added `email` field to Session type
### Version History Feature
**Backend API Endpoints:**
- `GET /room/:roomId/history` - Get version history
- `GET /room/:roomId/snapshot/:hash` - Get snapshot at version
- `POST /room/:roomId/diff` - Compute diff between versions
- `POST /room/:roomId/revert` - Revert to a version
**Frontend Components:**
- `VersionHistoryPanel.tsx` - Timeline with diff visualization
- `useVersionHistory.ts` - React hook for programmatic access
- GREEN highlighting for added shapes
- RED highlighting for removed shapes
- PURPLE highlighting for modified shapes
### Other Fixes
- Network graph connect/trust buttons now work
- CryptID dropdown integration buttons improved
- Obsidian vault connection modal added
Pushed to dev branch: commit 195cc7f
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,35 @@
---
id: task-049
title: Implement second device verification for CryptID
status: To Do
assignee: []
created_date: '2025-12-10 22:24'
labels:
- cryptid
- auth
- security
- testing
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Set up and test second device verification flow for the CryptID authentication system. This ensures users can recover their account and verify identity across multiple devices.
Key areas to implement/verify:
- QR code scanning between devices for key sharing
- Email backup verification flow
- Device linking and trust establishment
- Recovery flow when primary device is lost
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Second device can scan QR code to link account
- [ ] #2 Email backup sends verification code correctly (via Resend)
- [ ] #3 Linked devices can both access the same account
- [ ] #4 Recovery flow works when primary device unavailable
- [ ] #5 Test across different browsers/devices
<!-- AC:END -->

View File

@ -0,0 +1,52 @@
---
id: task-050
title: Implement Make-Real Feature (Wireframe to Working Prototype)
status: To Do
assignee: []
created_date: '2025-12-14 18:32'
labels:
- feature
- ai
- canvas
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement the full make-real workflow that converts wireframe sketches/designs on the canvas into working HTML/CSS/JS prototypes using AI.
## Current State
The backend infrastructure is ~60% complete:
- ✅ `makeRealSettings` atom in `src/lib/settings.tsx` with provider/model/API key configs
- ✅ System prompt in `src/prompt.ts` for wireframe-to-prototype conversion
- ✅ LLM backend in `src/utils/llmUtils.ts` with OpenAI, Anthropic, Ollama, RunPod support
- ✅ Settings migration in `src/routes/Board.tsx` loading `makereal_settings_2`
- ✅ "Make Real" placeholder in AI_TOOLS dropdown
## Missing Components
1. **Selection-to-image capture** - Export selected shapes as base64 PNG
2. **`makeReal()` action function** - Orchestrate the capture → AI → render pipeline
3. **ResponseShape/PreviewShape** - Custom tldraw shape to render generated HTML in iframe
4. **UI trigger** - Button/keyboard shortcut to invoke make-real on selection
5. **Iteration support** - Allow annotations on generated output for refinement
## Reference Implementation
- tldraw make-real demo: https://github.com/tldraw/make-real
- Key files to reference: `makeReal.ts`, `ResponseShape.tsx`, `getSelectionAsImageDataUrl.ts`
## Old Branch
`remotes/origin/make-real-integration` exists but is very outdated with errors - needs complete rewrite rather than merge.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 User can select shapes on canvas and trigger make-real action
- [ ] #2 Selection is captured as image and sent to configured AI provider
- [ ] #3 AI generates HTML/CSS/JS prototype based on wireframe and system prompt
- [ ] #4 Generated prototype renders in interactive iframe on canvas (ResponseShape)
- [ ] #5 User can annotate/modify and re-run make-real for iterations
- [ ] #6 Settings modal allows configuring provider/model/API keys
- [ ] #7 Works with Ollama (free), OpenAI, and Anthropic backends
<!-- AC:END -->

View File

@ -0,0 +1,59 @@
---
id: task-051
title: Offline storage and cold reload from offline state
status: In Progress
assignee: []
created_date: '2025-12-15 04:58'
updated_date: '2025-12-15 04:58'
labels:
- feature
- offline
- storage
- IndexedDB
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement offline storage fallback so that when a browser reloads without network connectivity, it automatically loads from local IndexedDB storage and renders the last known state of the board for that user.
## Implementation Summary (Completed)
### Changes Made:
1. **Board.tsx** - Updated render condition to allow rendering when offline with local data (`isOfflineWithLocalData` flag)
2. **useAutomergeStoreV2** - Added `isNetworkOnline` parameter and offline fast path that immediately loads records from Automerge doc without waiting for network patches
3. **useAutomergeSyncRepo** - Passes `isNetworkOnline` to `useAutomergeStoreV2`
4. **ConnectionStatusIndicator** - Updated messaging to clarify users are viewing locally cached canvas when offline
### How It Works:
1. useAutomergeSyncRepo detects no network and loads data from IndexedDB
2. useAutomergeStoreV2 receives handle with local data and detects offline state
3. Offline Fast Path immediately loads records into TLDraw store
4. Board.tsx renders with local data
5. ConnectionStatusIndicator shows "Working Offline - Viewing locally saved canvas"
6. When back online, Automerge automatically syncs via CRDT merge
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Board renders from local IndexedDB when browser reloads offline
- [ ] #2 User sees 'Working Offline' indicator with clear messaging
- [ ] #3 Changes made offline are saved locally
- [ ] #4 Auto-sync when network connectivity returns
- [ ] #5 No data loss during offline/online transitions
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
## Testing Required
- Test cold reload while offline (airplane mode)
- Test with board containing various shape types
- Test transition from offline to online (auto-sync)
- Test making changes while offline and syncing
- Verify no data loss scenarios
Commit: 4df9e42 pushed to dev branch
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,79 @@
---
id: task-052
title: 'Flip permissions model: everyone edits by default, protected boards opt-in'
status: Done
assignee: []
created_date: '2025-12-15 17:23'
updated_date: '2025-12-15 19:26'
labels: []
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Change the default permission model so ALL users (including anonymous) can edit by default. Boards can be marked as "protected" by an admin, making them view-only for non-designated users.
Key changes:
1. Add is_protected column to boards table
2. Add global_admins table (jeffemmett@gmail.com as initial admin)
3. Flip getEffectivePermission logic
4. Create BoardSettingsDropdown component with view-only toggle
5. Add user invite for protected boards
6. Admin request email flow
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Anonymous users can edit unprotected boards
- [x] #2 Protected boards are view-only for non-editors
- [x] #3 Global admin (jeffemmett@gmail.com) has admin on all boards
- [x] #4 Settings dropdown shows view-only toggle for admins
- [x] #5 Can add/remove editors on protected boards
- [x] #6 Admin request button sends email
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
## Implementation Complete (Dec 15, 2025)
### Backend Changes (commit 2fe96fa)
- **worker/schema.sql**: Added `is_protected` column to boards, created `global_admins` table
- **worker/types.ts**: Added `GlobalAdmin` interface, extended `PermissionCheckResult`
- **worker/boardPermissions.ts**: Rewrote `getEffectivePermission()` with new logic, added `isGlobalAdmin()`, new API handlers
- **worker/worker.ts**: Added routes for `/boards/:boardId/info`, `/boards/:boardId/editors`, `/admin/request`
- **worker/migrations/001_add_protected_boards.sql**: Migration script created
### D1 Migration (executed manually)
```sql
ALTER TABLE boards ADD COLUMN is_protected INTEGER DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_boards_protected ON boards(is_protected);
CREATE TABLE IF NOT EXISTS global_admins (email TEXT PRIMARY KEY, added_at TEXT, added_by TEXT);
INSERT OR IGNORE INTO global_admins (email) VALUES ('jeffemmett@gmail.com');
```
### Frontend Changes (commit 3f71222)
- **src/ui/components.tsx**: Integrated board protection settings into existing settings dropdown
- Protection toggle (view-only mode)
- Editor list management (add/remove)
- Global Admin badge display
- **src/context/AuthContext.tsx**: Changed default permission to 'edit' for everyone
- **src/routes/Board.tsx**: Updated `isReadOnly` logic for new permission model
- **src/components/BoardSettingsDropdown.tsx**: Created standalone component (kept for reference)
### Worker Deployment
- Deployed to Cloudflare Workers (version 5ddd1e23-d32f-459f-bc5c-cf3f799ab93f)
### Remaining
- [ ] AC #6: Admin request email flow (Resend integration needed)
### Resend Email Integration (commit a46ce44)
- Added `RESEND_API_KEY` secret to Cloudflare Worker
- Fixed from email to use verified domain: `Canvas <noreply@jeffemmett.com>`
- Admin request emails will be sent to jeffemmett@gmail.com
- Test email sent successfully: ID 7113526b-ce1e-43e7-b18d-42b3d54823d1
**All acceptance criteria now complete!**
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,44 @@
---
id: task-053
title: Initial mycro-zine toolkit setup
status: Done
assignee: []
created_date: '2025-12-15 23:41'
updated_date: '2025-12-15 23:41'
labels:
- setup
- feature
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Created the mycro-zine repository with:
- Single-page print layout generator (2x4 grid, all 8 pages on one 8.5"x11" sheet)
- Prompt templates for AI content/image generation
- Example Undernet zine pages
- Support for US Letter and A4 paper sizes
- CLI and programmatic API
- Pushed to Gitea and GitHub
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Repository structure created
- [x] #2 Layout script generates single-page output
- [x] #3 Prompt templates created
- [x] #4 Example zine pages included
- [x] #5 Pushed to Gitea and GitHub
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Completed 2025-12-15. Repository at:
- Gitea: gitea.jeffemmett.com:jeffemmett/mycro-zine
- GitHub: github.com/Jeff-Emmett/mycro-zine
Test with: cd /home/jeffe/Github/mycro-zine && npm run example
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,42 @@
---
id: task-054
title: Re-enable Map tool with GPS location sharing
status: Done
assignee: []
created_date: '2025-12-15 23:40'
updated_date: '2025-12-15 23:40'
labels:
- feature
- map
- collaboration
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Re-enabled the Map tool in the toolbar and context menu. Added GPS location sharing feature allowing collaborators to share their real-time location on the map with colored markers.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Map tool visible in toolbar (globe icon)
- [x] #2 Map tool available in context menu under Create Tool
- [x] #3 GPS location sharing toggle button works
- [x] #4 Collaborator locations shown as colored markers
- [x] #5 GPS watch cleaned up on component unmount
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented in commit 2d9d216.
Changes:
- CustomToolbar.tsx: Uncommented Map tool
- CustomContextMenu.tsx: Uncommented Map tool in Create Tool submenu
- MapShapeUtil.tsx: Added GPS location sharing with collaborator markers
GPS feature includes toggle button, real-time location updates, colored markers for each collaborator, and proper cleanup on unmount.
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,75 @@
---
id: task-055
title: Integrate MycroZine generator tool into canvas
status: In Progress
assignee: []
created_date: '2025-12-15 23:41'
updated_date: '2025-12-18 23:24'
labels:
- feature
- canvas
- ai
- gemini
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Create a MycroZineGeneratorShape - an interactive tool on the canvas that allows users to generate complete 8-page mini-zines from a topic/prompt.
5-phase iterative workflow:
1. Ideation: User discusses content with Claude (conversational)
2. Drafts: Claude generates 8 draft pages using Gemini, spawns on canvas
3. Feedback: User gives spatial feedback on each page
4. Finalization: Claude integrates feedback into final versions
5. Print: Aggregate into single-page printable (2x4 grid)
Key requirements:
- Always use Gemini for image generation (latest model)
- Store completed zines as templates for reprinting
- Individual image shapes spawned on canvas for spatial feedback
- Single-page print layout (all 8 pages on one 8.5"x11" sheet)
References mycro-zine repo at /home/jeffe/Github/mycro-zine for layout utilities and prompt templates.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 MycroZineGeneratorShapeUtil.tsx created
- [x] #2 MycroZineGeneratorTool.ts created and registered
- [ ] #3 Ideation phase with embedded chat UI
- [ ] #4 Drafts phase generates 8 images via Gemini and spawns on canvas
- [ ] #5 Feedback phase collects user input per page
- [ ] #6 Finalizing phase regenerates pages with feedback
- [ ] #7 Complete phase with print-ready download and template save
- [ ] #8 Templates stored in localStorage for reprinting
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Starting implementation of full 5-phase MycroZineGenerator shape
Created MycroZineGeneratorShapeUtil.tsx with full 5-phase workflow (ideation, drafts, feedback, finalizing, complete)
Created MycroZineGeneratorTool.ts
Registered in Board.tsx
Build successful - no TypeScript errors
Integrated Gemini Nano Banana Pro for image generation:
- Updated standalone mycro-zine app (generate-page/route.ts) with fallback chain: Nano Banana Pro → Imagen 3 → Gemini 2.0 Flash → placeholder
- Updated canvas MycroZineGeneratorShapeUtil.tsx to call Gemini API directly with proper types
- Added getGeminiConfig() to clientConfig.ts for API key management
- Aspect ratio: 3:4 portrait for zine pages (825x1275 target dimensions)
2025-12-18: Fixed geo-restriction issue for image generation
- Direct Gemini API calls were blocked in EU (Netcup server location)
- Created RunPod serverless proxy (US-based) to bypass geo-restrictions
- Added /api/generate-image endpoint to zine.jeffemmett.com that returns base64
- Updated canvas MycroZineGeneratorShapeUtil to call zine.jeffemmett.com API instead of Gemini directly
- Image generation now works reliably from any location
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,75 @@
---
id: task-056
title: Test Infrastructure & Merge Readiness Tests
status: Done
assignee: []
created_date: '2025-12-18 07:25'
updated_date: '2025-12-18 07:26'
labels:
- testing
- ci-cd
- infrastructure
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Established comprehensive testing infrastructure to verify readiness for merging dev to main. Includes:
- Vitest for unit/integration tests
- Playwright for E2E tests
- Miniflare setup for worker tests
- GitHub Actions CI/CD pipeline with 80% coverage gate
Test coverage for:
- Automerge CRDT sync (collaboration tests)
- Offline storage/cold reload
- CryptID authentication (registration, login, device linking)
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Vitest configured with jsdom environment
- [x] #2 Playwright configured for E2E tests
- [x] #3 Unit tests for crypto and IndexedDB document mapping
- [x] #4 E2E tests for collaboration, offline mode, authentication
- [x] #5 GitHub Actions workflow for CI/CD
- [x] #6 All current tests passing
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
## Implementation Summary
### Files Created:
- `vitest.config.ts` - Vitest configuration with jsdom, coverage thresholds
- `playwright.config.ts` - Playwright E2E test configuration
- `tests/setup.ts` - Global test setup (mocks for matchMedia, ResizeObserver, etc.)
- `tests/mocks/indexeddb.ts` - fake-indexeddb utilities
- `tests/mocks/websocket.ts` - MockWebSocket for sync tests
- `tests/mocks/automerge.ts` - Test helpers for CRDT documents
- `tests/unit/cryptid/crypto.test.ts` - WebCrypto unit tests (14 tests)
- `tests/unit/offline/document-mapping.test.ts` - IndexedDB tests (13 tests)
- `tests/e2e/collaboration.spec.ts` - CRDT sync E2E tests
- `tests/e2e/offline-mode.spec.ts` - Offline storage E2E tests
- `tests/e2e/authentication.spec.ts` - CryptID auth E2E tests
- `.github/workflows/test.yml` - CI/CD pipeline
### Test Commands Added to package.json:
- `npm run test` - Run Vitest in watch mode
- `npm run test:run` - Run once
- `npm run test:coverage` - With coverage report
- `npm run test:e2e` - Run Playwright E2E tests
### Current Test Results:
- 27 unit tests passing
- E2E tests ready to run against dev server
### Next Steps:
- Add worker tests with Miniflare (task-056 continuation)
- Run E2E tests to verify collaboration/offline/auth flows
- Increase unit test coverage to 80%
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,24 @@
---
id: task-057
title: Set up Cloudflare WARP split tunnels for Claude Code
status: Done
assignee: []
created_date: '2025-12-19 01:10'
labels: []
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Configured Cloudflare Zero Trust split tunnel excludes to allow Claude Code to work in WSL2 with WARP enabled on Windows.
Completed:
- Created Zero Trust API token with device config permissions
- Added localhost (127.0.0.0/8) to excludes
- Added Anthropic domains (api.anthropic.com, claude.ai, anthropic.com)
- Private networks already excluded (172.16.0.0/12, 192.168.0.0/16, 10.0.0.0/8)
- Created ~/bin/warp-split-tunnel CLI tool for future management
- Saved token to Netcup ~/.cloudflare-credentials.env
<!-- SECTION:DESCRIPTION:END -->

1
dev-dist/registerSW.js Normal file
View File

@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

114
dev-dist/sw.js Normal file
View File

@ -0,0 +1,114 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-52f2a342'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.n708e9nairg"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
workbox.registerRoute(/^https?:\/\/.*\/api\/.*/i, new workbox.NetworkFirst({
"cacheName": "api-cache",
"networkTimeoutSeconds": 10,
plugins: [new workbox.ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 86400
})]
}), 'GET');
workbox.registerRoute(/^https:\/\/fonts\.googleapis\.com\/.*/i, new workbox.CacheFirst({
"cacheName": "google-fonts-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 31536000
})]
}), 'GET');
workbox.registerRoute(/^https:\/\/fonts\.gstatic\.com\/.*/i, new workbox.CacheFirst({
"cacheName": "gstatic-fonts-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 31536000
})]
}), 'GET');
}));

4622
dev-dist/workbox-52f2a342.js Normal file

File diff suppressed because it is too large Load Diff

31
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,31 @@
# Canvas Website - Dev Branch Deployment
# Automatically deploys from `dev` branch for testing
# Access at: staging.jeffemmett.com
services:
canvas-dev:
build:
context: .
dockerfile: Dockerfile
args:
- VITE_WORKER_ENV=staging
container_name: canvas-dev
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.services.canvas-dev.loadbalancer.server.port=80"
- "traefik.http.routers.canvas-dev.rule=Host(`staging.jeffemmett.com`)"
- "traefik.http.routers.canvas-dev.entrypoints=web"
- "traefik.http.routers.canvas-dev.service=canvas-dev"
networks:
- traefik-public
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
networks:
traefik-public:
external: true

View File

@ -1,6 +1,6 @@
# Canvas Website Docker Compose
# Production: jeffemmett.com, www.jeffemmett.com
# Staging: staging.jeffemmett.com
# Dev branch: staging.jeffemmett.com (separate container via docker-compose.dev.yml)
services:
canvas-website:
@ -8,23 +8,18 @@ services:
context: .
dockerfile: Dockerfile
args:
- VITE_TLDRAW_WORKER_URL=https://jeffemmett-canvas.jeffemmett.workers.dev
- VITE_WORKER_ENV=production
# Add other build args from .env if needed
container_name: canvas-website
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
# Single service definition (both routers use same backend)
- "traefik.http.services.canvas.loadbalancer.server.port=80"
# Production deployment (jeffemmett.com and www)
- "traefik.http.routers.canvas-prod.rule=Host(`jeffemmett.com`) || Host(`www.jeffemmett.com`)"
- "traefik.http.routers.canvas-prod.entrypoints=web"
- "traefik.http.routers.canvas-prod.service=canvas"
# Staging deployment (keep for testing)
- "traefik.http.routers.canvas-staging.rule=Host(`staging.jeffemmett.com`)"
- "traefik.http.routers.canvas-staging.entrypoints=web"
- "traefik.http.routers.canvas-staging.service=canvas"
networks:
- traefik-public
healthcheck:

View File

@ -0,0 +1,913 @@
# Google Data Sovereignty: Local-First Secure Storage
This document outlines the architecture for securely importing, storing, and optionally sharing Google Workspace data (Gmail, Drive, Photos, Calendar) using a **local-first, data sovereign** approach.
## Overview
**Philosophy**: Your data should be yours. Import it locally, encrypt it client-side, and choose when/what to share.
```
┌─────────────────────────────────────────────────────────────────────────┐
│ USER'S BROWSER (Data Sovereign Zone) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌──────────────────────────────────────────────┐ │
│ │ Google APIs │───>│ Local Processing Layer │ │
│ │ (OAuth 2.0) │ │ ├── Fetch data │ │
│ └─────────────┘ │ ├── Encrypt with user's WebCrypto keys │ │
│ │ └── Store to IndexedDB │ │
│ └────────────────────────┬─────────────────────┘ │
│ │ │
│ ┌───────────────────────────────────────────┴───────────────────────┐ │
│ │ IndexedDB Encrypted Storage │ │
│ │ ├── gmail_messages (encrypted blobs) │ │
│ │ ├── drive_documents (encrypted blobs) │ │
│ │ ├── photos_media (encrypted references) │ │
│ │ ├── calendar_events (encrypted data) │ │
│ │ └── encryption_metadata (key derivation info) │ │
│ └─────────────────────────────────────────────────────────────────── │
│ │ │
│ ┌────────────────────────┴───────────────────────┐ │
│ │ Share Decision Layer (User Controlled) │ │
│ │ ├── Keep Private (local only) │ │
│ │ ├── Share to Board (Automerge sync) │ │
│ │ └── Backup to R2 (encrypted cloud backup) │ │
│ └────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
## Browser Storage Capabilities & Limitations
### IndexedDB Storage
| Browser | Default Quota | Max Quota | Persistence |
|---------|--------------|-----------|-------------|
| Chrome/Edge | 60% of disk | Unlimited* | Persistent with permission |
| Firefox | 10% up to 10GB | 50% of disk | Persistent with permission |
| Safari | 1GB (lax) | ~1GB per origin | Non-persistent (7-day eviction) |
*Chrome "Unlimited" requires `navigator.storage.persist()` permission
### Storage API Persistence
```typescript
// Request persistent storage (prevents automatic eviction)
async function requestPersistentStorage(): Promise<boolean> {
if (navigator.storage && navigator.storage.persist) {
const isPersisted = await navigator.storage.persist();
console.log(`Persistent storage ${isPersisted ? 'granted' : 'denied'}`);
return isPersisted;
}
return false;
}
// Check current storage quota
async function checkStorageQuota(): Promise<{used: number, quota: number}> {
if (navigator.storage && navigator.storage.estimate) {
const estimate = await navigator.storage.estimate();
return {
used: estimate.usage || 0,
quota: estimate.quota || 0
};
}
return { used: 0, quota: 0 };
}
```
### Safari's 7-Day Eviction Rule
**CRITICAL for Safari users**: Safari evicts IndexedDB data after 7 days of non-use.
**Mitigations**:
1. Use a Service Worker with periodic background sync to "touch" data
2. Prompt Safari users to add to Home Screen (PWA mode bypasses some restrictions)
3. Automatically sync important data to R2 backup
4. Show clear warnings about Safari limitations
```typescript
// Detect Safari's storage limitations
function hasSafariLimitations(): boolean {
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
return isSafari || isIOS;
}
// Register touch activity to prevent eviction
async function touchLocalData(): Promise<void> {
const db = await openDatabase();
const tx = db.transaction('metadata', 'readwrite');
tx.objectStore('metadata').put({
key: 'last_accessed',
timestamp: Date.now()
});
}
```
## Data Types & Storage Strategies
### 1. Gmail Messages
```typescript
interface EncryptedEmailStore {
id: string; // Gmail message ID
threadId: string; // Thread ID for grouping
encryptedSubject: ArrayBuffer; // AES-GCM encrypted
encryptedBody: ArrayBuffer; // AES-GCM encrypted
encryptedFrom: ArrayBuffer; // Sender info
encryptedTo: ArrayBuffer[]; // Recipients
date: number; // Timestamp (unencrypted for sorting)
labels: string[]; // Gmail labels (encrypted or not based on sensitivity)
hasAttachments: boolean; // Flag only, attachments stored separately
snippet: ArrayBuffer; // Encrypted preview
// Metadata for search (encrypted bloom filter or encrypted index)
searchIndex: ArrayBuffer;
// Sync metadata
syncedAt: number;
localOnly: boolean; // Not yet synced to any external storage
}
// Storage estimate per email:
// - Average email: ~20KB raw → ~25KB encrypted
// - With attachments: varies, but reference stored, not full attachment
// - 10,000 emails ≈ 250MB
```
### 2. Google Drive Documents
```typescript
interface EncryptedDriveDocument {
id: string; // Drive file ID
encryptedName: ArrayBuffer;
encryptedMimeType: ArrayBuffer;
encryptedContent: ArrayBuffer; // For text-based docs
encryptedPreview: ArrayBuffer; // Thumbnail or preview
// Large files: store reference, not content
contentStrategy: 'inline' | 'reference' | 'chunked';
chunks?: string[]; // IDs of content chunks if chunked
// Hierarchy
parentId: string | null;
path: ArrayBuffer; // Encrypted path string
// Sharing & permissions (for UI display)
isShared: boolean;
modifiedTime: number;
size: number; // Unencrypted for quota management
syncedAt: number;
}
// Storage considerations:
// - Google Docs: Convert to markdown/HTML, typically 10-100KB
// - Spreadsheets: JSON export, 100KB-10MB depending on size
// - PDFs: Store reference only, load on demand
// - Images: Thumbnail locally, full resolution on demand
```
### 3. Google Photos
```typescript
interface EncryptedPhotoReference {
id: string; // Photos media item ID
encryptedFilename: ArrayBuffer;
encryptedDescription: ArrayBuffer;
// Thumbnails stored locally (encrypted)
thumbnail: {
width: number;
height: number;
encryptedData: ArrayBuffer; // Base64 or blob
};
// Full resolution: reference only (fetch on demand)
fullResolution: {
width: number;
height: number;
// NOT storing full image - too large
// Fetch via API when user requests
};
mediaType: 'image' | 'video';
creationTime: number;
// Album associations
albumIds: string[];
// Location data (highly sensitive - always encrypted)
encryptedLocation?: ArrayBuffer;
syncedAt: number;
}
// Storage strategy:
// - Thumbnails: ~50KB each, store locally
// - Full images: NOT stored locally (too large)
// - 1,000 photos thumbnails ≈ 50MB
// - Full resolution loaded via API on demand
```
### 4. Google Calendar Events
```typescript
interface EncryptedCalendarEvent {
id: string; // Calendar event ID
calendarId: string;
encryptedSummary: ArrayBuffer;
encryptedDescription: ArrayBuffer;
encryptedLocation: ArrayBuffer;
// Time data (unencrypted for query/sort performance)
startTime: number;
endTime: number;
isAllDay: boolean;
timezone: string;
// Recurrence
isRecurring: boolean;
encryptedRecurrence?: ArrayBuffer;
// Attendees (encrypted)
encryptedAttendees: ArrayBuffer;
// Reminders
reminders: { method: string; minutes: number }[];
// Meeting links (encrypted - sensitive)
encryptedMeetingLink?: ArrayBuffer;
syncedAt: number;
}
// Storage estimate:
// - Average event: ~5KB encrypted
// - 2 years of events (~3000): ~15MB
```
## Encryption Strategy
### Key Derivation
Using the existing WebCrypto infrastructure, derive data encryption keys from the user's master key:
```typescript
// Derive a data-specific encryption key from master key
async function deriveDataEncryptionKey(
masterKey: CryptoKey,
purpose: 'gmail' | 'drive' | 'photos' | 'calendar'
): Promise<CryptoKey> {
const encoder = new TextEncoder();
const purposeBytes = encoder.encode(`canvas-data-${purpose}`);
// Import master key for HKDF
const baseKey = await crypto.subtle.importKey(
'raw',
await crypto.subtle.exportKey('raw', masterKey),
'HKDF',
false,
['deriveKey']
);
// Derive purpose-specific key
return await crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: purposeBytes,
info: new ArrayBuffer(0)
},
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
```
### Encryption/Decryption
```typescript
// Encrypt data before storing
async function encryptData(
data: string | ArrayBuffer,
key: CryptoKey
): Promise<{encrypted: ArrayBuffer, iv: Uint8Array}> {
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV for AES-GCM
const dataBuffer = typeof data === 'string'
? new TextEncoder().encode(data)
: data;
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
dataBuffer
);
return { encrypted, iv };
}
// Decrypt data when reading
async function decryptData(
encrypted: ArrayBuffer,
iv: Uint8Array,
key: CryptoKey
): Promise<ArrayBuffer> {
return await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
encrypted
);
}
```
## IndexedDB Schema
```typescript
// Database schema for encrypted Google data
const GOOGLE_DATA_DB = 'canvas-google-data';
const DB_VERSION = 1;
interface GoogleDataSchema {
gmail: {
key: string; // message ID
indexes: ['threadId', 'date', 'syncedAt'];
};
drive: {
key: string; // file ID
indexes: ['parentId', 'modifiedTime', 'mimeType'];
};
photos: {
key: string; // media item ID
indexes: ['creationTime', 'mediaType'];
};
calendar: {
key: string; // event ID
indexes: ['calendarId', 'startTime', 'endTime'];
};
syncMetadata: {
key: string; // 'gmail' | 'drive' | 'photos' | 'calendar'
// Stores last sync token, sync progress, etc.
};
encryptionKeys: {
key: string; // purpose
// Stores IV, salt for key derivation
};
}
async function initGoogleDataDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(GOOGLE_DATA_DB, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Gmail store
if (!db.objectStoreNames.contains('gmail')) {
const gmailStore = db.createObjectStore('gmail', { keyPath: 'id' });
gmailStore.createIndex('threadId', 'threadId', { unique: false });
gmailStore.createIndex('date', 'date', { unique: false });
gmailStore.createIndex('syncedAt', 'syncedAt', { unique: false });
}
// Drive store
if (!db.objectStoreNames.contains('drive')) {
const driveStore = db.createObjectStore('drive', { keyPath: 'id' });
driveStore.createIndex('parentId', 'parentId', { unique: false });
driveStore.createIndex('modifiedTime', 'modifiedTime', { unique: false });
}
// Photos store
if (!db.objectStoreNames.contains('photos')) {
const photosStore = db.createObjectStore('photos', { keyPath: 'id' });
photosStore.createIndex('creationTime', 'creationTime', { unique: false });
photosStore.createIndex('mediaType', 'mediaType', { unique: false });
}
// Calendar store
if (!db.objectStoreNames.contains('calendar')) {
const calendarStore = db.createObjectStore('calendar', { keyPath: 'id' });
calendarStore.createIndex('calendarId', 'calendarId', { unique: false });
calendarStore.createIndex('startTime', 'startTime', { unique: false });
}
// Sync metadata
if (!db.objectStoreNames.contains('syncMetadata')) {
db.createObjectStore('syncMetadata', { keyPath: 'service' });
}
// Encryption metadata
if (!db.objectStoreNames.contains('encryptionMeta')) {
db.createObjectStore('encryptionMeta', { keyPath: 'purpose' });
}
};
});
}
```
## Google OAuth & API Integration
### OAuth 2.0 Scopes
```typescript
const GOOGLE_SCOPES = {
// Read-only access (data sovereignty - we import, not modify)
gmail: 'https://www.googleapis.com/auth/gmail.readonly',
drive: 'https://www.googleapis.com/auth/drive.readonly',
photos: 'https://www.googleapis.com/auth/photoslibrary.readonly',
calendar: 'https://www.googleapis.com/auth/calendar.readonly',
// Profile for user identification
profile: 'https://www.googleapis.com/auth/userinfo.profile',
email: 'https://www.googleapis.com/auth/userinfo.email'
};
// Selective scope request - user chooses what to import
function getRequestedScopes(services: string[]): string {
const scopes = [GOOGLE_SCOPES.profile, GOOGLE_SCOPES.email];
services.forEach(service => {
if (GOOGLE_SCOPES[service as keyof typeof GOOGLE_SCOPES]) {
scopes.push(GOOGLE_SCOPES[service as keyof typeof GOOGLE_SCOPES]);
}
});
return scopes.join(' ');
}
```
### OAuth Flow with PKCE
```typescript
interface GoogleAuthState {
codeVerifier: string;
redirectUri: string;
state: string;
}
async function initiateGoogleAuth(services: string[]): Promise<void> {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = crypto.randomUUID();
// Store state for verification
sessionStorage.setItem('google_auth_state', JSON.stringify({
codeVerifier,
state,
redirectUri: window.location.origin + '/oauth/google/callback'
}));
const params = new URLSearchParams({
client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
redirect_uri: window.location.origin + '/oauth/google/callback',
response_type: 'code',
scope: getRequestedScopes(services),
access_type: 'offline', // Get refresh token
prompt: 'consent',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state
});
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
}
// PKCE helpers
function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(hash));
}
```
### Token Storage (Encrypted)
```typescript
interface EncryptedTokens {
accessToken: ArrayBuffer; // Encrypted
refreshToken: ArrayBuffer; // Encrypted
accessTokenIv: Uint8Array;
refreshTokenIv: Uint8Array;
expiresAt: number; // Unencrypted for refresh logic
scopes: string[]; // Unencrypted for UI display
}
async function storeGoogleTokens(
tokens: { access_token: string; refresh_token?: string; expires_in: number },
encryptionKey: CryptoKey
): Promise<void> {
const { encrypted: encAccessToken, iv: accessIv } = await encryptData(
tokens.access_token,
encryptionKey
);
const encryptedTokens: Partial<EncryptedTokens> = {
accessToken: encAccessToken,
accessTokenIv: accessIv,
expiresAt: Date.now() + (tokens.expires_in * 1000)
};
if (tokens.refresh_token) {
const { encrypted: encRefreshToken, iv: refreshIv } = await encryptData(
tokens.refresh_token,
encryptionKey
);
encryptedTokens.refreshToken = encRefreshToken;
encryptedTokens.refreshTokenIv = refreshIv;
}
const db = await initGoogleDataDB();
const tx = db.transaction('encryptionMeta', 'readwrite');
tx.objectStore('encryptionMeta').put({
purpose: 'google_tokens',
...encryptedTokens
});
}
```
## Data Import Workflow
### Progressive Import with Background Sync
```typescript
interface ImportProgress {
service: 'gmail' | 'drive' | 'photos' | 'calendar';
total: number;
imported: number;
lastSyncToken?: string;
status: 'idle' | 'importing' | 'paused' | 'error';
errorMessage?: string;
}
class GoogleDataImporter {
private encryptionKey: CryptoKey;
private db: IDBDatabase;
async importGmail(options: {
maxMessages?: number;
labelsFilter?: string[];
dateAfter?: Date;
}): Promise<void> {
const accessToken = await this.getAccessToken();
// Use pagination for large mailboxes
let pageToken: string | undefined;
let imported = 0;
do {
const response = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages?${new URLSearchParams({
maxResults: '100',
...(pageToken && { pageToken }),
...(options.labelsFilter && { labelIds: options.labelsFilter.join(',') }),
...(options.dateAfter && { q: `after:${Math.floor(options.dateAfter.getTime() / 1000)}` })
})}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
const data = await response.json();
// Fetch and encrypt each message
for (const msg of data.messages || []) {
const fullMessage = await this.fetchGmailMessage(msg.id, accessToken);
await this.storeEncryptedEmail(fullMessage);
imported++;
// Update progress
this.updateProgress('gmail', imported);
// Yield to UI periodically
if (imported % 10 === 0) {
await new Promise(r => setTimeout(r, 0));
}
}
pageToken = data.nextPageToken;
} while (pageToken && (!options.maxMessages || imported < options.maxMessages));
}
private async storeEncryptedEmail(message: any): Promise<void> {
const emailKey = await deriveDataEncryptionKey(this.encryptionKey, 'gmail');
const encrypted: EncryptedEmailStore = {
id: message.id,
threadId: message.threadId,
encryptedSubject: (await encryptData(
this.extractHeader(message, 'Subject') || '',
emailKey
)).encrypted,
encryptedBody: (await encryptData(
this.extractBody(message),
emailKey
)).encrypted,
// ... other fields
date: parseInt(message.internalDate),
syncedAt: Date.now(),
localOnly: true
};
const tx = this.db.transaction('gmail', 'readwrite');
tx.objectStore('gmail').put(encrypted);
}
}
```
## Sharing to Canvas Board
### Selective Sharing Model
```typescript
interface ShareableItem {
type: 'email' | 'document' | 'photo' | 'event';
id: string;
// Decrypted data for sharing
decryptedData: any;
}
class DataSharingService {
/**
* Share a specific item to the current board
* This decrypts the item and adds it to the Automerge document
*/
async shareToBoard(
item: ShareableItem,
boardHandle: DocumentHandle<CanvasDoc>,
userKey: CryptoKey
): Promise<void> {
// 1. Decrypt the item
const decrypted = await this.decryptItem(item, userKey);
// 2. Create a canvas shape representation
const shape = this.createShapeFromItem(decrypted, item.type);
// 3. Add to Automerge document (syncs to other board users)
boardHandle.change(doc => {
doc.shapes[shape.id] = shape;
});
// 4. Mark item as shared (no longer localOnly)
await this.markAsShared(item.id, item.type);
}
/**
* Create a visual shape from data
*/
private createShapeFromItem(data: any, type: string): TLShape {
switch (type) {
case 'email':
return {
id: createShapeId(),
type: 'email-card',
props: {
subject: data.subject,
from: data.from,
date: data.date,
snippet: data.snippet
}
};
case 'event':
return {
id: createShapeId(),
type: 'calendar-event',
props: {
title: data.summary,
startTime: data.startTime,
endTime: data.endTime,
location: data.location
}
};
// ... other types
}
}
}
```
## R2 Encrypted Backup
### Backup Architecture
```
User Browser Cloudflare Worker R2 Storage
│ │ │
│ 1. Encrypt data locally │ │
│ (already encrypted in IndexedDB) │ │
│ │ │
│ 2. Generate backup key │ │
│ (derived from master key) │ │
│ │ │
│ 3. POST encrypted blob ──────────> 4. Validate user │
│ │ (CryptID auth) │
│ │ │
│ │ 5. Store blob ─────────────────> │
│ │ (already encrypted, │
│ │ worker can't read) │
│ │ │
<──────────────────────────────── 6. Return backup ID │
```
### Backup Implementation
```typescript
interface BackupMetadata {
id: string;
createdAt: number;
services: ('gmail' | 'drive' | 'photos' | 'calendar')[];
itemCount: number;
sizeBytes: number;
// Encrypted with user's key - only they can read
encryptedManifest: ArrayBuffer;
}
class R2BackupService {
private workerUrl = '/api/backup';
async createBackup(
services: string[],
encryptionKey: CryptoKey
): Promise<BackupMetadata> {
// 1. Gather all encrypted data from IndexedDB
const dataToBackup = await this.gatherData(services);
// 2. Create a manifest (encrypted)
const manifest = {
version: 1,
createdAt: Date.now(),
services,
itemCounts: dataToBackup.counts
};
const { encrypted: encManifest } = await encryptData(
JSON.stringify(manifest),
encryptionKey
);
// 3. Serialize and chunk if large
const blob = await this.serializeForBackup(dataToBackup);
// 4. Upload to R2 via worker
const response = await fetch(this.workerUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'X-Backup-Manifest': base64Encode(encManifest)
},
body: blob
});
const { backupId } = await response.json();
return {
id: backupId,
createdAt: Date.now(),
services: services as any,
itemCount: Object.values(dataToBackup.counts).reduce((a, b) => a + b, 0),
sizeBytes: blob.size,
encryptedManifest: encManifest
};
}
async restoreBackup(
backupId: string,
encryptionKey: CryptoKey
): Promise<void> {
// 1. Fetch encrypted blob from R2
const response = await fetch(`${this.workerUrl}/${backupId}`);
const encryptedBlob = await response.arrayBuffer();
// 2. Data is already encrypted with user's key
// Just write directly to IndexedDB
await this.writeToIndexedDB(encryptedBlob);
}
}
```
## Privacy & Security Guarantees
### What Never Leaves the Browser (Unencrypted)
1. **Email content** - body, subject, attachments
2. **Document content** - file contents, names
3. **Photo data** - images, location metadata
4. **Calendar details** - event descriptions, attendee info
5. **OAuth tokens** - access/refresh tokens
### What the Server Never Sees
1. **Encryption keys** - derived locally, never transmitted
2. **Plaintext data** - all API calls are client-side
3. **User's Google account data** - we use read-only scopes
### Data Flow Summary
```
┌─────────────────────┐
│ Google APIs │
│ (authenticated) │
└──────────┬──────────┘
┌─────────▼─────────┐
│ Browser Fetch │
│ (client-side) │
└─────────┬─────────┘
┌─────────▼─────────┐
│ Encrypt with │
│ WebCrypto │
│ (AES-256-GCM) │
└─────────┬─────────┘
┌────────────────────┼────────────────────┐
│ │ │
┌─────────▼─────────┐ ┌───────▼────────┐ ┌────────▼───────┐
│ IndexedDB │ │ Share to │ │ R2 Backup │
│ (local only) │ │ Board │ │ (encrypted) │
│ │ │ (Automerge) │ │ │
└───────────────────┘ └────────────────┘ └────────────────┘
│ │ │
▼ ▼ ▼
Only you can read Board members Only you can
(your keys) see shared items decrypt backup
```
## Implementation Phases
### Phase 1: Foundation
- [ ] IndexedDB schema for encrypted data
- [ ] Key derivation from existing WebCrypto keys
- [ ] Encrypt/decrypt utility functions
- [ ] Storage quota monitoring
### Phase 2: Google OAuth
- [ ] OAuth 2.0 with PKCE flow
- [ ] Token encryption and storage
- [ ] Token refresh logic
- [ ] Scope selection UI
### Phase 3: Data Import
- [ ] Gmail import with pagination
- [ ] Drive document import
- [ ] Photos thumbnail import
- [ ] Calendar event import
- [ ] Progress tracking UI
### Phase 4: Canvas Integration
- [ ] Email card shape
- [ ] Document preview shape
- [ ] Photo thumbnail shape
- [ ] Calendar event shape
- [ ] Share to board functionality
### Phase 5: R2 Backup
- [ ] Encrypted backup creation
- [ ] Backup restore
- [ ] Backup management UI
- [ ] Automatic backup scheduling
### Phase 6: Polish
- [ ] Safari storage warnings
- [ ] Offline data access
- [ ] Search within encrypted data
- [ ] Data export (Google Takeout style)
## Security Checklist
- [ ] All data encrypted before storage
- [ ] Keys never leave browser unencrypted
- [ ] OAuth tokens encrypted at rest
- [ ] PKCE used for OAuth flow
- [ ] Read-only Google API scopes
- [ ] Safari 7-day eviction handled
- [ ] Storage quota warnings
- [ ] Secure context required (HTTPS)
- [ ] CSP headers configured
- [ ] No sensitive data in console logs
## Related Documents
- [Local File Upload](./LOCAL_FILE_UPLOAD.md) - Multi-item upload with same encryption model
- [Offline Storage Feasibility](../OFFLINE_STORAGE_FEASIBILITY.md) - IndexedDB + Automerge foundation
## References
- [IndexedDB API](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)
- [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API)
- [Storage API](https://developer.mozilla.org/en-US/docs/Web/API/Storage_API)
- [Google OAuth 2.0](https://developers.google.com/identity/protocols/oauth2)
- [Gmail API](https://developers.google.com/gmail/api)
- [Drive API](https://developers.google.com/drive/api)
- [Photos Library API](https://developers.google.com/photos/library/reference/rest)
- [Calendar API](https://developers.google.com/calendar/api)

862
docs/LOCAL_FILE_UPLOAD.md Normal file
View File

@ -0,0 +1,862 @@
# Local File Upload: Multi-Item Encrypted Import
A simpler, more broadly compatible approach to importing local files into the canvas with the same privacy-first, encrypted storage model.
## Overview
Instead of maintaining persistent folder connections (which have browser compatibility issues), provide a **drag-and-drop / file picker** interface for batch importing files into encrypted local storage.
```
┌─────────────────────────────────────────────────────────────────────────┐
│ UPLOAD INTERFACE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 📁 Drop files here or click to browse │ │
│ │ │ │
│ │ Supports: Images, PDFs, Documents, Text, Audio, Video │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Import Queue [Upload] │ │
│ ├──────────────────────────────────────────────────────────────────┤ │
│ │ ☑ photo_001.jpg (2.4 MB) 🔒 Encrypt 📤 Share │ │
│ │ ☑ meeting_notes.pdf (450 KB) 🔒 Encrypt ☐ Private │ │
│ │ ☑ project_plan.md (12 KB) 🔒 Encrypt ☐ Private │ │
│ │ ☐ sensitive_doc.docx (1.2 MB) 🔒 Encrypt ☐ Private │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ Storage: 247 MB used / ~5 GB available │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
## Why Multi-Item Upload vs. Folder Connection
| Feature | Folder Connection | Multi-Item Upload |
|---------|------------------|-------------------|
| Browser Support | Chrome/Edge only | All browsers |
| Persistent Access | Yes (with permission) | No (one-time import) |
| Implementation | Complex | Simple |
| User Control | Less explicit | Very explicit |
| Privacy UX | Hidden | Clear per-file choices |
**Recommendation**: Multi-item upload is better for privacy-conscious users who want explicit control over what enters the system.
## Supported File Types
### Documents
| Type | Extension | Processing | Storage Strategy |
|------|-----------|-----------|------------------|
| Markdown | `.md` | Parse frontmatter, render | Full content |
| PDF | `.pdf` | Extract text, thumbnail | Text + thumbnail |
| Word | `.docx` | Convert to markdown | Converted content |
| Text | `.txt`, `.csv`, `.json` | Direct | Full content |
| Code | `.js`, `.ts`, `.py`, etc. | Syntax highlight | Full content |
### Images
| Type | Extension | Processing | Storage Strategy |
|------|-----------|-----------|------------------|
| Photos | `.jpg`, `.png`, `.webp` | Generate thumbnail | Thumbnail + full |
| Vector | `.svg` | Direct | Full content |
| GIF | `.gif` | First frame thumb | Thumbnail + full |
### Media
| Type | Extension | Processing | Storage Strategy |
|------|-----------|-----------|------------------|
| Audio | `.mp3`, `.wav`, `.m4a` | Waveform preview | Reference + metadata |
| Video | `.mp4`, `.webm` | Frame thumbnail | Reference + metadata |
### Archives (Future)
| Type | Extension | Processing |
|------|-----------|-----------|
| ZIP | `.zip` | List contents, selective extract |
| Obsidian Export | `.zip` | Vault structure import |
## Architecture
```typescript
interface UploadedFile {
id: string; // Generated UUID
originalName: string; // User's filename
mimeType: string;
size: number;
// Processing results
processed: {
thumbnail?: ArrayBuffer; // For images/PDFs/videos
extractedText?: string; // For searchable docs
metadata?: Record<string, any>; // EXIF, frontmatter, etc.
};
// Encryption
encrypted: {
content: ArrayBuffer; // Encrypted file content
iv: Uint8Array;
keyId: string; // Reference to encryption key
};
// User choices
sharing: {
localOnly: boolean; // Default true
sharedToBoard?: string; // Board ID if shared
backedUpToR2?: boolean;
};
// Timestamps
importedAt: number;
lastAccessedAt: number;
}
```
## Implementation
### 1. File Input Component
```typescript
import React, { useCallback, useState } from 'react';
interface FileUploadProps {
onFilesSelected: (files: File[]) => void;
maxFileSize?: number; // bytes
maxFiles?: number;
acceptedTypes?: string[];
}
export function FileUploadZone({
onFilesSelected,
maxFileSize = 100 * 1024 * 1024, // 100MB default
maxFiles = 50,
acceptedTypes
}: FileUploadProps) {
const [isDragging, setIsDragging] = useState(false);
const [errors, setErrors] = useState<string[]>([]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
validateAndProcess(files);
}, []);
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
validateAndProcess(files);
}, []);
const validateAndProcess = (files: File[]) => {
const errors: string[] = [];
const validFiles: File[] = [];
for (const file of files.slice(0, maxFiles)) {
if (file.size > maxFileSize) {
errors.push(`${file.name}: exceeds ${maxFileSize / 1024 / 1024}MB limit`);
continue;
}
if (acceptedTypes && !acceptedTypes.some(t => file.type.match(t))) {
errors.push(`${file.name}: unsupported file type`);
continue;
}
validFiles.push(file);
}
if (files.length > maxFiles) {
errors.push(`Only first ${maxFiles} files will be imported`);
}
setErrors(errors);
if (validFiles.length > 0) {
onFilesSelected(validFiles);
}
};
return (
<div
onDrop={handleDrop}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
className={`upload-zone ${isDragging ? 'dragging' : ''}`}
>
<input
type="file"
multiple
onChange={handleFileInput}
accept={acceptedTypes?.join(',')}
id="file-upload"
hidden
/>
<label htmlFor="file-upload">
<span className="upload-icon">📁</span>
<span>Drop files here or click to browse</span>
<span className="upload-hint">
Images, PDFs, Documents, Text files
</span>
</label>
{errors.length > 0 && (
<div className="upload-errors">
{errors.map((err, i) => <div key={i}>{err}</div>)}
</div>
)}
</div>
);
}
```
### 2. File Processing Pipeline
```typescript
interface ProcessedFile {
file: File;
thumbnail?: Blob;
extractedText?: string;
metadata?: Record<string, any>;
}
class FileProcessor {
async process(file: File): Promise<ProcessedFile> {
const result: ProcessedFile = { file };
// Route based on MIME type
if (file.type.startsWith('image/')) {
return this.processImage(file, result);
} else if (file.type === 'application/pdf') {
return this.processPDF(file, result);
} else if (file.type.startsWith('text/') || this.isTextFile(file)) {
return this.processText(file, result);
} else if (file.type.startsWith('video/')) {
return this.processVideo(file, result);
} else if (file.type.startsWith('audio/')) {
return this.processAudio(file, result);
}
// Default: store as-is
return result;
}
private async processImage(file: File, result: ProcessedFile): Promise<ProcessedFile> {
// Generate thumbnail
const img = await createImageBitmap(file);
const canvas = new OffscreenCanvas(200, 200);
const ctx = canvas.getContext('2d')!;
// Calculate aspect-ratio preserving dimensions
const scale = Math.min(200 / img.width, 200 / img.height);
const w = img.width * scale;
const h = img.height * scale;
ctx.drawImage(img, (200 - w) / 2, (200 - h) / 2, w, h);
result.thumbnail = await canvas.convertToBlob({ type: 'image/webp', quality: 0.8 });
// Extract EXIF if available
if (file.type === 'image/jpeg') {
result.metadata = await this.extractExif(file);
}
return result;
}
private async processPDF(file: File, result: ProcessedFile): Promise<ProcessedFile> {
// Use pdf.js for text extraction and thumbnail
const pdfjsLib = await import('pdfjs-dist');
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
// Get first page as thumbnail
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 0.5 });
const canvas = new OffscreenCanvas(viewport.width, viewport.height);
const ctx = canvas.getContext('2d')!;
await page.render({ canvasContext: ctx, viewport }).promise;
result.thumbnail = await canvas.convertToBlob({ type: 'image/webp' });
// Extract text from all pages
let text = '';
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
text += content.items.map((item: any) => item.str).join(' ') + '\n';
}
result.extractedText = text;
result.metadata = { pageCount: pdf.numPages };
return result;
}
private async processText(file: File, result: ProcessedFile): Promise<ProcessedFile> {
result.extractedText = await file.text();
// Parse markdown frontmatter if applicable
if (file.name.endsWith('.md')) {
const frontmatter = this.parseFrontmatter(result.extractedText);
if (frontmatter) {
result.metadata = frontmatter;
}
}
return result;
}
private async processVideo(file: File, result: ProcessedFile): Promise<ProcessedFile> {
// Generate thumbnail from first frame
const video = document.createElement('video');
video.preload = 'metadata';
video.src = URL.createObjectURL(file);
await new Promise(resolve => video.addEventListener('loadedmetadata', resolve));
video.currentTime = 1; // First second
await new Promise(resolve => video.addEventListener('seeked', resolve));
const canvas = new OffscreenCanvas(200, 200);
const ctx = canvas.getContext('2d')!;
const scale = Math.min(200 / video.videoWidth, 200 / video.videoHeight);
ctx.drawImage(video, 0, 0, video.videoWidth * scale, video.videoHeight * scale);
result.thumbnail = await canvas.convertToBlob({ type: 'image/webp' });
result.metadata = {
duration: video.duration,
width: video.videoWidth,
height: video.videoHeight
};
URL.revokeObjectURL(video.src);
return result;
}
private async processAudio(file: File, result: ProcessedFile): Promise<ProcessedFile> {
// Extract duration and basic metadata
const audio = document.createElement('audio');
audio.src = URL.createObjectURL(file);
await new Promise(resolve => audio.addEventListener('loadedmetadata', resolve));
result.metadata = {
duration: audio.duration
};
URL.revokeObjectURL(audio.src);
return result;
}
private isTextFile(file: File): boolean {
const textExtensions = ['.md', '.txt', '.json', '.csv', '.yaml', '.yml', '.xml', '.html', '.css', '.js', '.ts', '.py', '.sh'];
return textExtensions.some(ext => file.name.toLowerCase().endsWith(ext));
}
private parseFrontmatter(content: string): Record<string, any> | null {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return null;
try {
// Simple YAML-like parsing (or use a proper YAML parser)
const lines = match[1].split('\n');
const result: Record<string, any> = {};
for (const line of lines) {
const [key, ...valueParts] = line.split(':');
if (key && valueParts.length) {
result[key.trim()] = valueParts.join(':').trim();
}
}
return result;
} catch {
return null;
}
}
private async extractExif(file: File): Promise<Record<string, any>> {
// Would use exif-js or similar library
return {};
}
}
```
### 3. Encryption & Storage
```typescript
class LocalFileStore {
private db: IDBDatabase;
private encryptionKey: CryptoKey;
async storeFile(processed: ProcessedFile, options: {
shareToBoard?: boolean;
} = {}): Promise<UploadedFile> {
const fileId = crypto.randomUUID();
// Read file content
const content = await processed.file.arrayBuffer();
// Encrypt content
const iv = crypto.getRandomValues(new Uint8Array(12));
const encryptedContent = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
this.encryptionKey,
content
);
// Encrypt thumbnail if present
let encryptedThumbnail: ArrayBuffer | undefined;
let thumbnailIv: Uint8Array | undefined;
if (processed.thumbnail) {
thumbnailIv = crypto.getRandomValues(new Uint8Array(12));
const thumbBuffer = await processed.thumbnail.arrayBuffer();
encryptedThumbnail = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: thumbnailIv },
this.encryptionKey,
thumbBuffer
);
}
const uploadedFile: UploadedFile = {
id: fileId,
originalName: processed.file.name,
mimeType: processed.file.type,
size: processed.file.size,
processed: {
extractedText: processed.extractedText,
metadata: processed.metadata
},
encrypted: {
content: encryptedContent,
iv,
keyId: 'user-master-key'
},
sharing: {
localOnly: !options.shareToBoard,
sharedToBoard: options.shareToBoard ? getCurrentBoardId() : undefined
},
importedAt: Date.now(),
lastAccessedAt: Date.now()
};
// Store encrypted thumbnail separately (for faster listing)
if (encryptedThumbnail && thumbnailIv) {
await this.storeThumbnail(fileId, encryptedThumbnail, thumbnailIv);
}
// Store to IndexedDB
const tx = this.db.transaction('files', 'readwrite');
tx.objectStore('files').put(uploadedFile);
return uploadedFile;
}
async getFile(fileId: string): Promise<{
file: UploadedFile;
decryptedContent: ArrayBuffer;
} | null> {
const tx = this.db.transaction('files', 'readonly');
const file = await new Promise<UploadedFile | undefined>(resolve => {
const req = tx.objectStore('files').get(fileId);
req.onsuccess = () => resolve(req.result);
});
if (!file) return null;
// Decrypt content
const decryptedContent = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: file.encrypted.iv },
this.encryptionKey,
file.encrypted.content
);
return { file, decryptedContent };
}
async listFiles(options?: {
mimeTypeFilter?: string;
limit?: number;
offset?: number;
}): Promise<UploadedFile[]> {
const tx = this.db.transaction('files', 'readonly');
const store = tx.objectStore('files');
return new Promise(resolve => {
const files: UploadedFile[] = [];
const req = store.openCursor();
req.onsuccess = (e) => {
const cursor = (e.target as IDBRequest).result;
if (cursor) {
const file = cursor.value as UploadedFile;
// Filter by MIME type if specified
if (!options?.mimeTypeFilter || file.mimeType.startsWith(options.mimeTypeFilter)) {
files.push(file);
}
cursor.continue();
} else {
resolve(files);
}
};
});
}
}
```
### 4. IndexedDB Schema
```typescript
const LOCAL_FILES_DB = 'canvas-local-files';
const DB_VERSION = 1;
async function initLocalFilesDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(LOCAL_FILES_DB, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Main files store
if (!db.objectStoreNames.contains('files')) {
const store = db.createObjectStore('files', { keyPath: 'id' });
store.createIndex('mimeType', 'mimeType', { unique: false });
store.createIndex('importedAt', 'importedAt', { unique: false });
store.createIndex('originalName', 'originalName', { unique: false });
store.createIndex('sharedToBoard', 'sharing.sharedToBoard', { unique: false });
}
// Thumbnails store (separate for faster listing)
if (!db.objectStoreNames.contains('thumbnails')) {
db.createObjectStore('thumbnails', { keyPath: 'fileId' });
}
// Search index (encrypted full-text search)
if (!db.objectStoreNames.contains('searchIndex')) {
const searchStore = db.createObjectStore('searchIndex', { keyPath: 'fileId' });
searchStore.createIndex('tokens', 'tokens', { unique: false, multiEntry: true });
}
};
});
}
```
## UI Components
### Import Dialog
```tsx
function ImportFilesDialog({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
const [selectedFiles, setSelectedFiles] = useState<ProcessedFile[]>([]);
const [importing, setImporting] = useState(false);
const [progress, setProgress] = useState(0);
const fileStore = useLocalFileStore();
const handleFilesSelected = async (files: File[]) => {
const processor = new FileProcessor();
const processed: ProcessedFile[] = [];
for (const file of files) {
processed.push(await processor.process(file));
}
setSelectedFiles(prev => [...prev, ...processed]);
};
const handleImport = async () => {
setImporting(true);
for (let i = 0; i < selectedFiles.length; i++) {
await fileStore.storeFile(selectedFiles[i]);
setProgress((i + 1) / selectedFiles.length * 100);
}
setImporting(false);
onClose();
};
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle>Import Files</DialogTitle>
<FileUploadZone onFilesSelected={handleFilesSelected} />
{selectedFiles.length > 0 && (
<div className="file-list">
{selectedFiles.map((pf, i) => (
<FilePreviewRow
key={i}
file={pf}
onRemove={() => setSelectedFiles(prev => prev.filter((_, j) => j !== i))}
/>
))}
</div>
)}
{importing && (
<progress value={progress} max={100} />
)}
<DialogActions>
<button onClick={onClose}>Cancel</button>
<button
onClick={handleImport}
disabled={selectedFiles.length === 0 || importing}
>
Import {selectedFiles.length} files
</button>
</DialogActions>
</Dialog>
);
}
```
### File Browser Panel
```tsx
function LocalFilesBrowser() {
const [files, setFiles] = useState<UploadedFile[]>([]);
const [filter, setFilter] = useState<string>('all');
const fileStore = useLocalFileStore();
useEffect(() => {
loadFiles();
}, [filter]);
const loadFiles = async () => {
const mimeFilter = filter === 'all' ? undefined : filter;
setFiles(await fileStore.listFiles({ mimeTypeFilter: mimeFilter }));
};
const handleDragToCanvas = (file: UploadedFile) => {
// Create a shape from the file and add to canvas
};
return (
<div className="local-files-browser">
<div className="filter-bar">
<button onClick={() => setFilter('all')}>All</button>
<button onClick={() => setFilter('image/')}>Images</button>
<button onClick={() => setFilter('application/pdf')}>PDFs</button>
<button onClick={() => setFilter('text/')}>Documents</button>
</div>
<div className="files-grid">
{files.map(file => (
<FileCard
key={file.id}
file={file}
onDragStart={() => handleDragToCanvas(file)}
/>
))}
</div>
</div>
);
}
```
## Canvas Integration
### Drag Files to Canvas
```typescript
// When user drags a local file onto the canvas
async function createShapeFromLocalFile(
file: UploadedFile,
position: { x: number; y: number },
editor: Editor
): Promise<TLShapeId> {
const fileStore = getLocalFileStore();
const { decryptedContent } = await fileStore.getFile(file.id);
if (file.mimeType.startsWith('image/')) {
// Create image shape
const blob = new Blob([decryptedContent], { type: file.mimeType });
const assetId = AssetRecordType.createId();
await editor.createAssets([{
id: assetId,
type: 'image',
typeName: 'asset',
props: {
name: file.originalName,
src: URL.createObjectURL(blob),
w: 400,
h: 300,
mimeType: file.mimeType,
isAnimated: file.mimeType === 'image/gif'
}
}]);
return editor.createShape({
type: 'image',
x: position.x,
y: position.y,
props: { assetId, w: 400, h: 300 }
}).id;
} else if (file.mimeType === 'application/pdf') {
// Create PDF embed or preview shape
return editor.createShape({
type: 'pdf-preview',
x: position.x,
y: position.y,
props: {
fileId: file.id,
name: file.originalName,
pageCount: file.processed.metadata?.pageCount
}
}).id;
} else if (file.mimeType.startsWith('text/') || file.originalName.endsWith('.md')) {
// Create note shape with content
const text = new TextDecoder().decode(decryptedContent);
return editor.createShape({
type: 'note',
x: position.x,
y: position.y,
props: {
text: text.slice(0, 1000), // Truncate for display
fileId: file.id,
fullContentAvailable: text.length > 1000
}
}).id;
}
// Default: generic file card
return editor.createShape({
type: 'file-card',
x: position.x,
y: position.y,
props: {
fileId: file.id,
name: file.originalName,
size: file.size,
mimeType: file.mimeType
}
}).id;
}
```
## Storage Considerations
### Size Limits & Recommendations
| File Type | Max Recommended | Notes |
|-----------|----------------|-------|
| Images | 20MB each | Larger images get resized |
| PDFs | 50MB each | Text extracted for search |
| Videos | 100MB each | Store reference, thumbnail only |
| Audio | 50MB each | Store with waveform preview |
| Documents | 10MB each | Full content stored |
### Total Storage Budget
```typescript
const STORAGE_CONFIG = {
// Soft warning at 500MB
warningThreshold: 500 * 1024 * 1024,
// Hard limit at 2GB (leaves room for other data)
maxStorage: 2 * 1024 * 1024 * 1024,
// Auto-cleanup: remove thumbnails for files not accessed in 30 days
thumbnailRetentionDays: 30
};
async function checkStorageQuota(): Promise<{
used: number;
available: number;
warning: boolean;
}> {
const estimate = await navigator.storage.estimate();
const used = estimate.usage || 0;
const quota = estimate.quota || 0;
return {
used,
available: Math.min(quota - used, STORAGE_CONFIG.maxStorage - used),
warning: used > STORAGE_CONFIG.warningThreshold
};
}
```
## Privacy Features
### Per-File Privacy Controls
```typescript
interface FilePrivacySettings {
// Encryption is always on - this is about sharing
localOnly: boolean; // Never leaves browser
shareableToBoard: boolean; // Can be added to shared board
includeInR2Backup: boolean; // Include in cloud backup
// Metadata privacy
stripExif: boolean; // Remove location/camera data from images
anonymizeFilename: boolean; // Use generated name instead of original
}
const DEFAULT_PRIVACY: FilePrivacySettings = {
localOnly: true,
shareableToBoard: false,
includeInR2Backup: true,
stripExif: true,
anonymizeFilename: false
};
```
### Sharing Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ User drags local file onto shared board │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ⚠️ Share "meeting_notes.pdf" to this board? │
│ │
│ This file is currently private. Sharing it will: │
│ • Make it visible to all board members │
│ • Upload an encrypted copy to sync storage │
│ • Keep the original encrypted on your device │
│ │
│ [Keep Private] [Share to Board] │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## Implementation Checklist
### Phase 1: Core Upload
- [ ] File drop zone component
- [ ] File type detection
- [ ] Image thumbnail generation
- [ ] PDF text extraction & thumbnail
- [ ] Encryption before storage
- [ ] IndexedDB schema & storage
### Phase 2: File Management
- [ ] File browser panel
- [ ] Filter by type
- [ ] Search within files
- [ ] Delete files
- [ ] Storage quota display
### Phase 3: Canvas Integration
- [ ] Drag files to canvas
- [ ] Image shape from file
- [ ] PDF preview shape
- [ ] Document/note shape
- [ ] Generic file card shape
### Phase 4: Sharing & Backup
- [ ] Share confirmation dialog
- [ ] Upload to Automerge sync
- [ ] Include in R2 backup
- [ ] Privacy settings per file
## Related Documents
- [Google Data Sovereignty](./GOOGLE_DATA_SOVEREIGNTY.md) - Same encryption model for Google imports
- [Offline Storage Feasibility](../OFFLINE_STORAGE_FEASIBILITY.md) - IndexedDB + Automerge foundation

View File

@ -4,32 +4,42 @@
<head>
<title>Jeff Emmett</title>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🍄</text></svg>" />
<link rel="apple-touch-icon" href="/pwa-192x192.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="Permissions-Policy" content="midi=*, microphone=*, camera=*, autoplay=*">
<!-- Preconnect to critical origins for faster loading -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://jeffemmett-canvas.jeffemmett.workers.dev" />
<link rel="dns-prefetch" href="https://jeffemmett-canvas-dev.jeffemmett.workers.dev" />
<link rel="preconnect" href="https://jeffemmett-canvas.jeffemmett.workers.dev" crossorigin />
<link rel="preconnect" href="https://jeffemmett-canvas-dev.jeffemmett.workers.dev" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Recursive:slnt,wght,CASL,CRSV,MONO@-15..0,300..1000,0..1,0..1,0..1&display=swap"
rel="stylesheet">
<!-- Social Meta Tags -->
<meta name="description"
content="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.">
content="Exploring mycoeconomics, token engineering, psilo-cybernetics, zero-knowledge local-first systems, and institutional neuroplasticity. Research at the intersection of regenerative systems, crypto commons, and emancipatory technology.">
<meta property="og: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">
content="Exploring mycoeconomics, token engineering, psilo-cybernetics, zero-knowledge local-first systems, and institutional neuroplasticity. Research at the intersection of regenerative systems, crypto commons, and emancipatory technology.">
<meta property="og:image" content="https://jeffemmett.com/og-image.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<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">
content="Exploring mycoeconomics, token engineering, psilo-cybernetics, zero-knowledge local-first systems, and institutional neuroplasticity. Research at the intersection of regenerative systems, crypto commons, and emancipatory technology.">
<meta name="twitter:image" content="https://jeffemmett.com/og-image.jpg">
<!-- Analytics -->
<script data-goatcounter="https://jeff.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>

View File

@ -4,12 +4,25 @@ server {
root /usr/share/nginx/html;
index index.html;
# Gzip compression
# Gzip compression (fallback for clients that don't support Brotli)
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
gzip_comp_level 6;
gzip_min_length 256;
gzip_proxied any;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/wasm
application/octet-stream
image/svg+xml
font/woff2;
gzip_disable "MSIE [1-6]\.";
# Security headers
@ -18,7 +31,32 @@ server {
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Cache static assets
# NEVER cache index.html and service worker - always fetch fresh
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
location = /registerSW.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
location = /manifest.webmanifest {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# Cache static assets with hashed filenames (immutable)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";

View File

@ -0,0 +1,94 @@
# Open Mapping Backend Services
# Deploy to: /opt/apps/open-mapping/ on Netcup RS 8000
version: '3.8'
services:
# OSRM - Open Source Routing Machine
osrm:
image: osrm/osrm-backend:v5.27.1
container_name: open-mapping-osrm
restart: unless-stopped
volumes:
- ./data/osrm:/data:ro
command: osrm-routed --algorithm mld /data/germany-latest.osrm --max-table-size 10000
ports:
- "5000:5000"
networks:
- traefik-public
- open-mapping-internal
labels:
- "traefik.enable=true"
- "traefik.http.routers.osrm.rule=Host(`routing.jeffemmett.com`) && PathPrefix(`/osrm`)"
- "traefik.http.routers.osrm.middlewares=osrm-stripprefix"
- "traefik.http.middlewares.osrm-stripprefix.stripprefix.prefixes=/osrm"
- "traefik.http.services.osrm.loadbalancer.server.port=5000"
# Valhalla - Extended Routing
valhalla:
image: ghcr.io/gis-ops/docker-valhalla/valhalla:latest
container_name: open-mapping-valhalla
restart: unless-stopped
volumes:
- ./data/valhalla:/custom_files
environment:
- tile_urls=https://download.geofabrik.de/europe/germany-latest.osm.pbf
- use_tiles_ignore_pbf=True
- build_elevation=True
- build_admins=True
- build_time_zones=True
ports:
- "8002:8002"
networks:
- traefik-public
- open-mapping-internal
labels:
- "traefik.enable=true"
- "traefik.http.routers.valhalla.rule=Host(`routing.jeffemmett.com`) && PathPrefix(`/valhalla`)"
- "traefik.http.services.valhalla.loadbalancer.server.port=8002"
deploy:
resources:
limits:
memory: 8G
# TileServer GL - Vector Tiles
tileserver:
image: maptiler/tileserver-gl:v4.6.5
container_name: open-mapping-tiles
restart: unless-stopped
volumes:
- ./data/tiles:/data:ro
ports:
- "8080:8080"
networks:
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.tiles.rule=Host(`tiles.jeffemmett.com`)"
- "traefik.http.services.tiles.loadbalancer.server.port=8080"
# VROOM - Route Optimization
vroom:
image: vroomvrp/vroom-docker:v1.14.0
container_name: open-mapping-vroom
restart: unless-stopped
environment:
- VROOM_ROUTER=osrm
- OSRM_URL=http://osrm:5000
ports:
- "3000:3000"
networks:
- traefik-public
- open-mapping-internal
labels:
- "traefik.enable=true"
- "traefik.http.routers.vroom.rule=Host(`routing.jeffemmett.com`) && PathPrefix(`/optimize`)"
- "traefik.http.services.vroom.loadbalancer.server.port=3000"
depends_on:
- osrm
networks:
traefik-public:
external: true
open-mapping-internal:
driver: bridge

32
open-mapping.setup.sh Normal file
View File

@ -0,0 +1,32 @@
#!/bin/bash
# Open Mapping Backend Setup Script
# Run on Netcup RS 8000 to prepare routing data
set -e
REGION=${1:-germany}
DATA_DIR="/opt/apps/open-mapping/data"
echo "=== Open Mapping Setup ==="
echo "Region: $REGION"
mkdir -p "$DATA_DIR/osrm" "$DATA_DIR/valhalla" "$DATA_DIR/tiles"
cd "$DATA_DIR"
# Download OSM data
case $REGION in
germany) OSM_URL="https://download.geofabrik.de/europe/germany-latest.osm.pbf"; OSM_FILE="germany-latest.osm.pbf" ;;
europe) OSM_URL="https://download.geofabrik.de/europe-latest.osm.pbf"; OSM_FILE="europe-latest.osm.pbf" ;;
*) echo "Unknown region: $REGION"; exit 1 ;;
esac
[ ! -f "osrm/$OSM_FILE" ] && wget -O "osrm/$OSM_FILE" "$OSM_URL"
# Process OSRM data
cd osrm
[ ! -f "${OSM_FILE%.osm.pbf}.osrm" ] && docker run -t -v "${PWD}:/data" osrm/osrm-backend:v5.27.1 osrm-extract -p /opt/car.lua /data/$OSM_FILE
[ ! -f "${OSM_FILE%.osm.pbf}.osrm.partition" ] && docker run -t -v "${PWD}:/data" osrm/osrm-backend:v5.27.1 osrm-partition /data/${OSM_FILE%.osm.pbf}.osrm
[ ! -f "${OSM_FILE%.osm.pbf}.osrm.mldgr" ] && docker run -t -v "${PWD}:/data" osrm/osrm-backend:v5.27.1 osrm-customize /data/${OSM_FILE%.osm.pbf}.osrm
echo "=== Setup Complete ==="
echo "Next: docker compose up -d"

10209
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,14 +11,23 @@
"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": "NODE_OPTIONS=\"--max-old-space-size=8192\" tsc && NODE_OPTIONS=\"--max-old-space-size=8192\" 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": "NODE_OPTIONS=\"--max-old-space-size=8192\" tsc && NODE_OPTIONS=\"--max-old-space-size=8192\" vite build && wrangler deploy",
"deploy:pages": "NODE_OPTIONS=\"--max-old-space-size=8192\" tsc && NODE_OPTIONS=\"--max-old-space-size=8192\" vite build",
"deploy:worker": "wrangler deploy",
"deploy:worker:dev": "wrangler deploy --config wrangler.dev.toml",
"types": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:worker": "vitest run --config vitest.worker.config.ts",
"test:all": "vitest run && vitest run --config vitest.worker.config.ts && playwright test",
"multmux:install": "npm install --workspaces",
"multmux:build": "npm run build --workspace=@multmux/server --workspace=@multmux/cli",
"multmux:dev:server": "npm run dev --workspace=@multmux/server",
@ -37,10 +46,14 @@
"@chengsokdara/use-whisper": "^0.2.0",
"@daily-co/daily-js": "^0.60.0",
"@daily-co/daily-react": "^0.20.0",
"@fal-ai/client": "^1.7.2",
"@mdxeditor/editor": "^3.51.0",
"@react-three/drei": "^9.114.3",
"@react-three/fiber": "^8.17.10",
"@tldraw/assets": "^3.15.4",
"@tldraw/tldraw": "^3.15.4",
"@tldraw/tlschema": "^3.15.4",
"@types/d3": "^7.4.3",
"@types/markdown-it": "^14.1.1",
"@types/marked": "^5.0.2",
"@uiw/react-md-editor": "^4.0.5",
@ -51,19 +64,20 @@
"ajv": "^8.17.1",
"cherry-markdown": "^0.8.57",
"cloudflare-workers-unfurl": "^0.0.7",
"d3": "^7.9.0",
"fathom-typescript": "^0.0.36",
"gray-matter": "^4.0.3",
"gun": "^0.2020.1241",
"h3-js": "^4.3.0",
"holosphere": "^1.1.20",
"html2canvas": "^1.4.1",
"itty-router": "^5.0.17",
"jotai": "^2.6.0",
"jspdf": "^2.5.2",
"lodash.throttle": "^4.1.1",
"maplibre-gl": "^5.14.0",
"marked": "^15.0.4",
"one-webcrypto": "^1.0.3",
"openai": "^4.79.3",
"qrcode.react": "^4.2.0",
"rbush": "^4.0.1",
"react": "^18.2.0",
"react-cmdk": "^1.3.9",
@ -72,24 +86,39 @@
"react-router-dom": "^7.0.2",
"recoil": "^0.7.7",
"sharp": "^0.33.5",
"three": "^0.168.0",
"tldraw": "^3.15.4",
"use-whisper": "^0.0.1",
"webcola": "^3.4.0",
"webnative": "^0.36.3"
"webcola": "^3.4.0"
},
"devDependencies": {
"@cloudflare/types": "^6.0.0",
"@cloudflare/vitest-pool-workers": "^0.11.0",
"@cloudflare/workers-types": "^4.20240821.1",
"@playwright/test": "^1.57.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/lodash.throttle": "^4",
"@types/rbush": "^4.0.0",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"@vitejs/plugin-react": "^4.0.3",
"@vitest/coverage-v8": "^4.0.16",
"@vitest/ui": "^4.0.16",
"concurrently": "^9.1.0",
"fake-indexeddb": "^6.2.5",
"jsdom": "^27.0.1",
"miniflare": "^4.20251213.0",
"msw": "^2.12.4",
"playwright": "^1.57.0",
"typescript": "^5.6.3",
"vite": "^6.0.3",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
"vitest": "^3.2.4",
"wrangler": "^4.33.2"
},
"engines": {

45
playwright.config.ts Normal file
View File

@ -0,0 +1,45 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
timeout: 60000, // Increase timeout for canvas loading
expect: {
timeout: 10000
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 1, // Retry once locally too
workers: process.env.CI ? 1 : 4,
reporter: process.env.CI ? 'github' : 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// Only run other browsers in CI with full browser install
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
],
// Run local dev server before starting tests
webServer: {
command: 'npm run dev',
port: 5173,
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
})

BIN
public/og-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@ -1,23 +1,21 @@
import "tldraw/tldraw.css"
import "@/css/style.css"
import { Default } from "@/routes/Default"
import { BrowserRouter, Route, Routes, Navigate, useParams } from "react-router-dom"
import { Contact } from "@/routes/Contact"
import { Board } from "./routes/Board"
import { Inbox } from "./routes/Inbox"
import { Presentations } from "./routes/Presentations"
import { Resilience } from "./routes/Resilience"
import { 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 { BrowserRouter, Route, Routes, Navigate, useParams } from "react-router-dom";
import { createRoot } from "react-dom/client";
import { useState, useEffect, lazy, Suspense } from 'react';
// Lazy load heavy route components for faster initial load
const Default = lazy(() => import("@/routes/Default").then(m => ({ default: m.Default })));
const Contact = lazy(() => import("@/routes/Contact").then(m => ({ default: m.Contact })));
const Board = lazy(() => import("./routes/Board").then(m => ({ default: m.Board })));
const Inbox = lazy(() => import("./routes/Inbox").then(m => ({ default: m.Inbox })));
const Presentations = lazy(() => import("./routes/Presentations").then(m => ({ default: m.Presentations })));
const Resilience = lazy(() => import("./routes/Resilience").then(m => ({ default: m.Resilience })));
const Dashboard = lazy(() => import("./routes/Dashboard").then(m => ({ default: m.Dashboard })));
// Import React Context providers
import { AuthProvider, useAuth } from './context/AuthContext';
@ -30,19 +28,62 @@ import { ErrorBoundary } from './components/ErrorBoundary';
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();
// Import Google Data test component
import { GoogleDataTest } from './components/GoogleDataTest';
// Lazy load Daily.co provider - only needed for video chat
const DailyProvider = lazy(() =>
import('@daily-co/daily-react').then(m => ({ default: m.DailyProvider }))
);
// Loading skeleton for lazy-loaded routes
const LoadingSpinner = () => (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
width: '100vw',
background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)',
color: '#fff',
fontFamily: 'Inter, system-ui, sans-serif',
}}>
<div style={{
width: '48px',
height: '48px',
border: '3px solid rgba(255,255,255,0.1)',
borderTopColor: '#4f46e5',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
<p style={{ marginTop: '16px', fontSize: '14px', opacity: 0.7 }}>Loading canvas...</p>
<style>{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
</div>
);
// Daily.co call object - initialized lazily when needed
let dailyCallObject: any = null;
const getDailyCallObject = async () => {
if (dailyCallObject) return dailyCallObject;
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) {
const Daily = (await import('@daily-co/daily-js')).default;
dailyCallObject = Daily.createCallObject();
}
} catch (error) {
console.warn('Daily.co call object initialization failed:', error);
}
} catch (error) {
console.warn('Daily.co call object initialization failed:', error);
// Continue without video chat functionality
}
return dailyCallObject;
};
/**
* Optional Auth Route component
@ -103,69 +144,76 @@ const AppWithProviders = () => {
<AuthProvider>
<FileSystemProvider>
<NotificationProvider>
<DailyProvider callObject={callObject}>
<BrowserRouter>
{/* Display notifications */}
<NotificationsDisplay />
<Routes>
{/* Redirect routes without trailing slashes to include them */}
<Route path="/login" element={<Navigate to="/login/" replace />} />
<Route path="/contact" element={<Navigate to="/contact/" replace />} />
<Route path="/board/:slug" element={<RedirectBoardSlug />} />
<Route path="/inbox" element={<Navigate to="/inbox/" replace />} />
<Route path="/debug" element={<Navigate to="/debug/" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/" replace />} />
<Route path="/presentations" element={<Navigate to="/presentations/" replace />} />
<Route path="/presentations/resilience" element={<Navigate to="/presentations/resilience/" replace />} />
<Suspense fallback={<LoadingSpinner />}>
<DailyProvider callObject={null}>
<BrowserRouter>
{/* Display notifications */}
<NotificationsDisplay />
{/* Auth routes */}
<Route path="/login/" element={<AuthPage />} />
<Suspense fallback={<LoadingSpinner />}>
<Routes>
{/* Redirect routes without trailing slashes to include them */}
<Route path="/login" element={<Navigate to="/login/" replace />} />
<Route path="/contact" element={<Navigate to="/contact/" replace />} />
<Route path="/board/:slug" element={<RedirectBoardSlug />} />
<Route path="/inbox" element={<Navigate to="/inbox/" replace />} />
<Route path="/debug" element={<Navigate to="/debug/" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/" replace />} />
<Route path="/presentations" element={<Navigate to="/presentations/" replace />} />
<Route path="/presentations/resilience" element={<Navigate to="/presentations/resilience/" replace />} />
{/* 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>
{/* Auth routes */}
<Route path="/login/" element={<AuthPage />} />
{/* Optional auth routes - all lazy loaded */}
<Route path="/" element={
<OptionalAuthRoute>
<Default />
</OptionalAuthRoute>
} />
<Route path="/contact/" element={
<OptionalAuthRoute>
<Contact />
</OptionalAuthRoute>
} />
<Route path="/board/:slug/" element={
<OptionalAuthRoute>
<Board />
</OptionalAuthRoute>
} />
<Route path="/inbox/" element={
<OptionalAuthRoute>
<Inbox />
</OptionalAuthRoute>
} />
<Route path="/debug/" element={
<OptionalAuthRoute>
<CryptoDebug />
</OptionalAuthRoute>
} />
<Route path="/dashboard/" element={
<OptionalAuthRoute>
<Dashboard />
</OptionalAuthRoute>
} />
<Route path="/presentations/" element={
<OptionalAuthRoute>
<Presentations />
</OptionalAuthRoute>
} />
<Route path="/presentations/resilience/" element={
<OptionalAuthRoute>
<Resilience />
</OptionalAuthRoute>
} />
{/* Google Data routes */}
<Route path="/google" element={<GoogleDataTest />} />
<Route path="/oauth/google/callback" element={<GoogleDataTest />} />
</Routes>
</Suspense>
</BrowserRouter>
</DailyProvider>
</Suspense>
</NotificationProvider>
</FileSystemProvider>
</AuthProvider>

View File

@ -300,11 +300,9 @@ export function applyAutomergePatchesToTLStore(
case "unmark":
case "conflict": {
// These actions are not currently supported for TLDraw
console.log("Unsupported patch action:", patch.action)
break
}
default: {
console.log("Unsupported patch:", patch)
}
}
@ -422,7 +420,6 @@ export function applyAutomergePatchesToTLStore(
// Filter out SharedPiano shapes since they're no longer supported
if (record.typeName === 'shape' && (record as any).type === 'SharedPiano') {
console.log(`⚠️ Filtering out deprecated SharedPiano shape: ${record.id}`)
return // Skip - SharedPiano is deprecated
}
@ -444,24 +441,7 @@ export function applyAutomergePatchesToTLStore(
// put / remove the records in the store
// Log patch application for debugging
console.log(`🔧 AutomergeToTLStore: Applying ${patches.length} patches, ${toPut.length} records to put, ${toRemove.length} records to remove`)
// DEBUG: Log shape updates being applied to store
toPut.forEach(record => {
if (record.typeName === 'shape' && (record as any).props?.w) {
console.log(`🔧 AutomergeToTLStore: Putting shape ${(record as any).type} ${record.id}:`, {
w: (record as any).props.w,
h: (record as any).props.h,
x: (record as any).x,
y: (record as any).y
})
}
})
if (failedRecords.length > 0) {
console.log({ patches, toPut: toPut.length, failed: failedRecords.length })
}
if (failedRecords.length > 0) {
console.error("Failed to sanitize records:", failedRecords)
}
@ -695,14 +675,12 @@ export function sanitizeRecord(record: any): TLRecord {
// Normalize the shape type if it's a custom type with incorrect case
if (sanitized.type && typeof sanitized.type === 'string' && customShapeTypeMap[sanitized.type]) {
console.log(`🔧 Normalizing shape type: "${sanitized.type}" → "${customShapeTypeMap[sanitized.type]}"`)
sanitized.type = customShapeTypeMap[sanitized.type]
}
// CRITICAL: Sanitize Multmux shapes AFTER case normalization - ensure all required props exist
// Old shapes may have wsUrl (removed) or undefined values
if (sanitized.type === 'Multmux') {
console.log(`🔧 Sanitizing Multmux shape ${sanitized.id}:`, JSON.stringify(sanitized.props))
// Remove deprecated wsUrl prop
if ('wsUrl' in sanitized.props) {
delete sanitized.props.wsUrl
@ -762,7 +740,78 @@ export function sanitizeRecord(record: any): TLRecord {
}
sanitized.props = cleanProps
console.log(`🔧 Sanitized Multmux shape ${sanitized.id} props:`, JSON.stringify(sanitized.props))
}
// CRITICAL: Sanitize Map shapes - ensure all required props have defaults
// Old shapes may be missing pinnedToView, isMinimized, or other newer properties
if (sanitized.type === 'Map') {
// Ensure boolean props have proper defaults (old data may have undefined)
if (typeof sanitized.props.pinnedToView !== 'boolean') {
sanitized.props.pinnedToView = false
}
if (typeof sanitized.props.isMinimized !== 'boolean') {
sanitized.props.isMinimized = false
}
if (typeof sanitized.props.showSidebar !== 'boolean') {
sanitized.props.showSidebar = true
}
if (typeof sanitized.props.interactive !== 'boolean') {
sanitized.props.interactive = true
}
if (typeof sanitized.props.showGPS !== 'boolean') {
sanitized.props.showGPS = false
}
if (typeof sanitized.props.showSearch !== 'boolean') {
sanitized.props.showSearch = false
}
if (typeof sanitized.props.showDirections !== 'boolean') {
sanitized.props.showDirections = false
}
if (typeof sanitized.props.sharingLocation !== 'boolean') {
sanitized.props.sharingLocation = false
}
// Ensure array props exist
if (!Array.isArray(sanitized.props.annotations)) {
sanitized.props.annotations = []
}
if (!Array.isArray(sanitized.props.waypoints)) {
sanitized.props.waypoints = []
}
if (!Array.isArray(sanitized.props.collaborators)) {
sanitized.props.collaborators = []
}
if (!Array.isArray(sanitized.props.gpsUsers)) {
sanitized.props.gpsUsers = []
}
if (!Array.isArray(sanitized.props.tags)) {
sanitized.props.tags = ['map']
}
// Ensure string props exist
if (typeof sanitized.props.styleKey !== 'string') {
sanitized.props.styleKey = 'voyager'
}
if (typeof sanitized.props.title !== 'string') {
sanitized.props.title = 'Collaborative Map'
}
if (typeof sanitized.props.description !== 'string') {
sanitized.props.description = ''
}
// Ensure viewport exists with defaults
if (!sanitized.props.viewport || typeof sanitized.props.viewport !== 'object') {
sanitized.props.viewport = {
center: { lat: 40.7128, lng: -74.006 },
zoom: 12,
bearing: 0,
pitch: 0,
}
}
// Ensure numeric props
if (typeof sanitized.props.w !== 'number' || isNaN(sanitized.props.w)) {
sanitized.props.w = 800
}
if (typeof sanitized.props.h !== 'number' || isNaN(sanitized.props.h)) {
sanitized.props.h = 550
}
}
// CRITICAL: Infer type from properties BEFORE defaulting to 'geo'

View File

@ -23,20 +23,16 @@ export class CloudflareAdapter {
async getHandle(roomId: string): Promise<DocHandle<TLStoreSnapshot>> {
if (!this.handles.has(roomId)) {
console.log(`Creating new Automerge handle for room ${roomId}`)
const handle = this.repo.create<TLStoreSnapshot>()
// Initialize with default store if this is a new document
handle.change((doc) => {
if (!doc.store) {
console.log("Initializing new document with default store")
init(doc)
}
})
this.handles.set(roomId, handle)
} else {
console.log(`Reusing existing Automerge handle for room ${roomId}`)
}
return this.handles.get(roomId)!
@ -72,13 +68,11 @@ export class CloudflareAdapter {
async saveToCloudflare(roomId: string): Promise<void> {
const handle = this.handles.get(roomId)
if (!handle) {
console.log(`No handle found for room ${roomId}`)
return
}
const doc = handle.doc()
if (!doc) {
console.log(`No document found for room ${roomId}`)
return
}
@ -114,7 +108,6 @@ export class CloudflareAdapter {
async loadFromCloudflare(roomId: string): Promise<TLStoreSnapshot | null> {
try {
// Add retry logic for connection issues
let response: Response;
let retries = 3;
@ -131,7 +124,7 @@ export class CloudflareAdapter {
}
}
}
if (!response!.ok) {
if (response!.status === 404) {
return null // Room doesn't exist yet
@ -141,12 +134,7 @@ export class CloudflareAdapter {
}
const doc = await response!.json() as TLStoreSnapshot
console.log(`Successfully loaded document from Cloudflare for room ${roomId}:`, {
hasStore: !!doc.store,
storeKeys: doc.store ? Object.keys(doc.store).length : 0
})
// Initialize the last persisted state with the loaded document
if (doc) {
const docHash = this.generateDocHash(doc)
@ -161,12 +149,17 @@ export class CloudflareAdapter {
}
}
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
export class CloudflareNetworkAdapter extends NetworkAdapter {
private workerUrl: string
private websocket: WebSocket | null = null
private roomId: string | null = null
public peerId: PeerId | undefined = undefined
public sessionId: string | null = null // Track our session ID
private serverPeerId: PeerId | null = null // The server's peer ID for Automerge sync
private currentDocumentId: string | null = null // Track the current document ID for sync messages
private pendingBinaryMessages: Uint8Array[] = [] // Buffer for binary messages received before documentId is set
private readyPromise: Promise<void>
private readyResolve: (() => void) | null = null
private keepAliveInterval: NodeJS.Timeout | null = null
@ -177,21 +170,78 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
private isConnecting: boolean = false
private onJsonSyncData?: (data: any) => void
private onPresenceUpdate?: (userId: string, data: any, senderId?: string, userName?: string, userColor?: string) => void
private onPresenceLeave?: (sessionId: string) => void
// Binary sync mode - when true, uses native Automerge sync protocol
private useBinarySync: boolean = true
// Connection state tracking
private _connectionState: ConnectionState = 'disconnected'
private connectionStateListeners: Set<(state: ConnectionState) => void> = new Set()
private _isNetworkOnline: boolean = typeof navigator !== 'undefined' ? navigator.onLine : true
get connectionState(): ConnectionState {
return this._connectionState
}
get isNetworkOnline(): boolean {
return this._isNetworkOnline
}
private setConnectionState(state: ConnectionState): void {
if (this._connectionState !== state) {
this._connectionState = state
this.connectionStateListeners.forEach(listener => listener(state))
}
}
onConnectionStateChange(listener: (state: ConnectionState) => void): () => void {
this.connectionStateListeners.add(listener)
// Immediately call with current state
listener(this._connectionState)
return () => this.connectionStateListeners.delete(listener)
}
private networkOnlineHandler: () => void
private networkOfflineHandler: () => void
constructor(
workerUrl: string,
roomId?: string,
onJsonSyncData?: (data: any) => void,
onPresenceUpdate?: (userId: string, data: any, senderId?: string, userName?: string, userColor?: string) => void
onPresenceUpdate?: (userId: string, data: any, senderId?: string, userName?: string, userColor?: string) => void,
onPresenceLeave?: (sessionId: string) => void
) {
super()
this.workerUrl = workerUrl
this.roomId = roomId || 'default-room'
this.onJsonSyncData = onJsonSyncData
this.onPresenceUpdate = onPresenceUpdate
this.onPresenceLeave = onPresenceLeave
this.readyPromise = new Promise((resolve) => {
this.readyResolve = resolve
})
// Set up network online/offline listeners
this.networkOnlineHandler = () => {
this._isNetworkOnline = true
// Trigger reconnect if we were disconnected
if (this._connectionState === 'disconnected' && this.peerId) {
this.setConnectionState('reconnecting')
this.connect(this.peerId)
}
}
this.networkOfflineHandler = () => {
this._isNetworkOnline = false
if (this._connectionState === 'connected') {
this.setConnectionState('disconnected')
}
}
if (typeof window !== 'undefined') {
window.addEventListener('online', this.networkOnlineHandler)
window.addEventListener('offline', this.networkOfflineHandler)
}
}
isReady(): boolean {
@ -202,15 +252,50 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
return this.readyPromise
}
/**
* Set the document ID for this adapter
* This is needed because the server may send sync messages before we've sent any
* @param documentId The Automerge document ID to use for incoming messages
*/
setDocumentId(documentId: string): void {
this.currentDocumentId = documentId
// Process any buffered binary messages now that we have a documentId
if (this.pendingBinaryMessages.length > 0) {
const bufferedMessages = this.pendingBinaryMessages
this.pendingBinaryMessages = []
for (const binaryData of bufferedMessages) {
const message: Message = {
type: 'sync',
data: binaryData,
senderId: this.serverPeerId || ('server' as PeerId),
targetId: this.peerId || ('unknown' as PeerId),
documentId: this.currentDocumentId as any
}
this.emit('message', message)
}
}
}
/**
* Get the current document ID
*/
getDocumentId(): string | null {
return this.currentDocumentId
}
connect(peerId: PeerId, peerMetadata?: PeerMetadata): void {
if (this.isConnecting) {
console.log('🔌 CloudflareAdapter: Connection already in progress, skipping')
return
}
// Store peerId
this.peerId = peerId
// Set connection state
this.setConnectionState(this.reconnectAttempts > 0 ? 'reconnecting' : 'connecting')
// Clean up existing connection
this.cleanup()
@ -225,19 +310,31 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
const wsUrl = `${protocol}${baseUrl}/connect/${this.roomId}?sessionId=${sessionId}`
this.isConnecting = true
// Add a small delay to ensure the server is ready
setTimeout(() => {
try {
console.log('🔌 CloudflareAdapter: Creating WebSocket connection to:', wsUrl)
this.websocket = new WebSocket(wsUrl)
this.websocket.onopen = () => {
console.log('🔌 CloudflareAdapter: WebSocket connection opened successfully')
this.isConnecting = false
this.reconnectAttempts = 0
this.setConnectionState('connected')
this.readyResolve?.()
this.startKeepAlive()
// Emit 'ready' event for Automerge Repo
// @ts-expect-error - 'ready' event is valid but not in NetworkAdapterEvents type
this.emit('ready', { network: this })
// Create a server peer ID based on the room
this.serverPeerId = `server-${this.roomId}` as PeerId
// Emit 'peer-candidate' to announce the server as a sync peer
this.emit('peer-candidate', {
peerId: this.serverPeerId,
peerMetadata: { storageId: undefined, isEphemeral: false }
})
}
this.websocket.onmessage = (event) => {
@ -245,25 +342,32 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
// Automerge's native protocol uses binary messages
// We need to handle both binary and text messages
if (event.data instanceof ArrayBuffer) {
console.log('🔌 CloudflareAdapter: Received binary message (Automerge protocol)')
// Handle binary Automerge sync messages - convert ArrayBuffer to Uint8Array
// Automerge Repo expects binary sync messages as Uint8Array
const binaryData = new Uint8Array(event.data)
if (!this.currentDocumentId) {
this.pendingBinaryMessages.push(binaryData)
return
}
const message: Message = {
type: 'sync',
data: new Uint8Array(event.data),
senderId: this.peerId || ('unknown' as PeerId),
targetId: this.peerId || ('unknown' as PeerId)
data: binaryData,
senderId: this.serverPeerId || ('server' as PeerId),
targetId: this.peerId || ('unknown' as PeerId),
documentId: this.currentDocumentId as any
}
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 binaryData = new Uint8Array(buffer)
if (!this.currentDocumentId) {
this.pendingBinaryMessages.push(binaryData)
return
}
const message: Message = {
type: 'sync',
data: new Uint8Array(buffer),
senderId: this.peerId || ('unknown' as PeerId),
targetId: this.peerId || ('unknown' as PeerId)
data: binaryData,
senderId: this.serverPeerId || ('server' as PeerId),
targetId: this.peerId || ('unknown' as PeerId),
documentId: this.currentDocumentId as any
}
this.emit('message', message)
})
@ -271,11 +375,6 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
// Handle text messages (our custom protocol for backward compatibility)
const message = JSON.parse(event.data)
// Only log non-presence messages to reduce console spam
if (message.type !== 'presence' && message.type !== 'pong') {
console.log('🔌 CloudflareAdapter: Received WebSocket message:', message.type)
}
// Handle ping/pong messages for keep-alive
if (message.type === 'ping') {
this.sendPong()
@ -284,55 +383,44 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
// Handle test messages
if (message.type === 'test') {
console.log('🔌 CloudflareAdapter: Received test message:', message.message)
return
}
// Handle presence updates from other clients
if (message.type === 'presence') {
// Pass senderId, userName, and userColor so we can create proper instance_presence records
if (this.onPresenceUpdate && message.userId && message.data) {
this.onPresenceUpdate(message.userId, message.data, message.senderId, message.userName, message.userColor)
}
return
}
// Handle leave messages (user disconnected)
if (message.type === 'leave') {
if (this.onPresenceLeave && message.sessionId) {
this.onPresenceLeave(message.sessionId)
}
return
}
// Convert the message to the format expected by Automerge
if (message.type === 'sync' && message.data) {
console.log('🔌 CloudflareAdapter: Received sync message with data:', {
hasStore: !!message.data.store,
storeKeys: message.data.store ? Object.keys(message.data.store).length : 0,
documentId: message.documentId,
documentIdType: typeof message.documentId
})
// JSON sync for real-time collaboration
// When we receive TLDraw changes from other clients, apply them locally
const isJsonDocumentData = message.data && typeof message.data === 'object' && message.data.store
if (isJsonDocumentData) {
console.log('📥 CloudflareAdapter: Received JSON sync message with store data')
// Call the JSON sync callback to apply changes
if (this.onJsonSyncData) {
this.onJsonSyncData(message.data)
} else {
console.warn('⚠️ No JSON sync callback registered')
}
return // JSON sync handled
return
}
// 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
// Validate documentId format
const isValidDocumentId = message.documentId &&
(typeof message.documentId === 'string' &&
(message.documentId.startsWith('automerge:') ||
message.documentId.includes(':') ||
/^[a-f0-9-]{36,}$/i.test(message.documentId)))
const syncMessage: Message = {
type: 'sync',
senderId: message.senderId || this.peerId || ('unknown' as PeerId),
@ -340,60 +428,40 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
data: message.data,
...(isValidDocumentId && { documentId: message.documentId })
}
if (message.documentId && !isValidDocumentId) {
console.warn('⚠️ CloudflareAdapter: Ignoring invalid documentId from server:', message.documentId)
}
this.emit('message', syncMessage)
} else if (message.senderId && message.targetId) {
this.emit('message', message as Message)
}
}
} catch (error) {
console.error('❌ CloudflareAdapter: Error parsing WebSocket message:', error)
console.error('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)')
if (event.code === 1000) {
this.setConnectionState('disconnected')
return // Don't reconnect on normal closure
}
// Set state based on whether we'll try to reconnect
if (this.reconnectAttempts < this.maxReconnectAttempts && this._isNetworkOnline) {
this.setConnectionState('reconnecting')
} else {
this.setConnectionState('disconnected')
}
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.websocket.onerror = () => {
this.isConnecting = false
}
} catch (error) {
@ -405,62 +473,25 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
}
send(message: Message): void {
// Only log non-presence messages to reduce console spam
if (message.type !== 'presence') {
console.log('📤 CloudflareAdapter.send() called:', {
messageType: message.type,
dataType: (message as any).data?.constructor?.name || typeof (message as any).data,
dataLength: (message as any).data?.byteLength || (message as any).data?.length,
documentId: (message as any).documentId,
hasTargetId: !!message.targetId,
hasSenderId: !!message.senderId
})
// Capture documentId from outgoing sync messages
if (message.type === 'sync' && (message as any).documentId) {
const docId = (message as any).documentId
if (this.currentDocumentId !== docId) {
this.currentDocumentId = docId
}
}
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
// Check if this is a binary sync message from Automerge Repo
if (message.type === 'sync' && (message as any).data instanceof ArrayBuffer) {
console.log('📤 CloudflareAdapter: Sending binary sync message (Automerge protocol)', {
dataLength: (message as any).data.byteLength,
documentId: (message as any).documentId,
targetId: message.targetId
})
// Send binary data directly for Automerge's native sync protocol
this.websocket.send((message as any).data)
return
} 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)
this.websocket.send((message as any).data)
return
} else {
// Handle text-based messages (backward compatibility and control messages)
// Only log non-presence messages
if (message.type !== 'presence') {
console.log('📤 Sending WebSocket message:', message.type)
}
// Debug: Log patch content if it's a patch message
if (message.type === 'patch' && (message as any).patches) {
console.log('🔍 Sending patches:', (message as any).patches.length, 'patches')
;(message as any).patches.forEach((patch: any, index: number) => {
console.log(` Patch ${index}:`, {
action: patch.action,
path: patch.path,
value: patch.value ? (typeof patch.value === 'object' ? 'object' : patch.value) : 'undefined'
})
})
}
this.websocket.send(JSON.stringify(message))
}
} else {
if (message.type !== 'presence') {
console.warn('⚠️ CloudflareAdapter: Cannot send message - WebSocket not open', {
messageType: message.type,
readyState: this.websocket?.readyState
})
}
}
}
@ -473,14 +504,34 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
disconnect(): void {
this.cleanup()
this.roomId = null
this.setConnectionState('disconnected')
// Clean up network listeners
if (typeof window !== 'undefined') {
window.removeEventListener('online', this.networkOnlineHandler)
window.removeEventListener('offline', this.networkOfflineHandler)
}
this.connectionStateListeners.clear()
this.emit('close')
}
private cleanup(): void {
this.stopKeepAlive()
this.clearReconnectTimeout()
if (this.websocket) {
// Send leave message before closing to notify other clients
if (this.websocket.readyState === WebSocket.OPEN && this.sessionId) {
try {
this.websocket.send(JSON.stringify({
type: 'leave',
sessionId: this.sessionId
}))
} catch (e) {
// Ignore errors when sending leave message
}
}
this.websocket.close(1000, 'Client disconnecting')
this.websocket = null
}
@ -490,13 +541,12 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
// Send ping every 30 seconds to prevent idle timeout
this.keepAliveInterval = setInterval(() => {
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
console.log('🔌 CloudflareAdapter: Sending keep-alive ping')
this.websocket.send(JSON.stringify({
type: 'ping',
timestamp: Date.now()
}))
}
}, 30000) // 30 seconds
}, 30000)
}
private stopKeepAlive(): void {
@ -517,18 +567,14 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
private scheduleReconnect(peerId: PeerId, peerMetadata?: PeerMetadata): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('❌ CloudflareAdapter: Max reconnection attempts reached, giving up')
return
}
this.reconnectAttempts++
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000) // Max 30 seconds
console.log(`🔄 CloudflareAdapter: Scheduling reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`)
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000)
this.reconnectTimeout = setTimeout(() => {
if (this.roomId) {
console.log(`🔄 CloudflareAdapter: Attempting reconnect ${this.reconnectAttempts}/${this.maxReconnectAttempts}`)
this.connect(peerId, peerMetadata)
}
}, delay)

View File

@ -315,6 +315,19 @@ function sanitizeRecord(record: TLRecord): TLRecord {
(sanitized.props as any).richText = { content: [], type: 'doc' }
}
}
// CRITICAL: For text shapes, preserve richText property (required for text shapes)
// Text shapes store their content in props.richText, not props.text
if (sanitized.type === 'text') {
// 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 {
// Text 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') {
@ -449,49 +462,6 @@ export function applyTLStoreChangesToAutomerge(
originalX = (record as any).x
originalY = (record as any).y
}
// DEBUG: Log richText, meta.text, and Obsidian note properties before sanitization
if (record.typeName === 'shape') {
if (record.type === 'geo' && (record.props as any)?.richText) {
console.log(`🔍 TLStoreToAutomerge: Geo shape ${record.id} has richText before sanitization:`, {
hasRichText: !!(record.props as any).richText,
richTextType: typeof (record.props as any).richText,
richTextContent: Array.isArray((record.props as any).richText) ? 'array' : (record.props as any).richText?.content ? 'object with content' : 'object without content'
})
}
if (record.type === 'geo' && (record.meta as any)?.text !== undefined) {
console.log(`🔍 TLStoreToAutomerge: Geo shape ${record.id} has meta.text before sanitization:`, {
hasMetaText: !!(record.meta as any).text,
metaTextValue: (record.meta as any).text,
metaTextType: typeof (record.meta as any).text
})
}
if (record.type === 'note' && (record.props as any)?.richText) {
console.log(`🔍 TLStoreToAutomerge: Note shape ${record.id} has richText before sanitization:`, {
hasRichText: !!(record.props as any).richText,
richTextType: typeof (record.props as any).richText,
richTextContent: Array.isArray((record.props as any).richText) ? 'array' : (record.props as any).richText?.content ? 'object with content' : 'object without content',
richTextContentLength: Array.isArray((record.props as any).richText?.content) ? (record.props as any).richText.content.length : 'not array'
})
}
if (record.type === 'arrow' && (record.props as any)?.text !== undefined) {
console.log(`🔍 TLStoreToAutomerge: Arrow shape ${record.id} has text before sanitization:`, {
hasText: !!(record.props as any).text,
textValue: (record.props as any).text,
textType: typeof (record.props as any).text
})
}
if (record.type === 'ObsNote') {
console.log(`🔍 TLStoreToAutomerge: ObsNote shape ${record.id} before sanitization:`, {
hasTitle: !!(record.props as any).title,
hasContent: !!(record.props as any).content,
hasTags: Array.isArray((record.props as any).tags),
title: (record.props as any).title,
contentLength: (record.props as any).content?.length || 0,
tagsCount: Array.isArray((record.props as any).tags) ? (record.props as any).tags.length : 0
})
}
}
const sanitizedRecord = sanitizeRecord(record)
// CRITICAL: Restore original coordinates if they were valid
@ -505,99 +475,11 @@ export function applyTLStoreChangesToAutomerge(
}
}
// DEBUG: Log richText, meta.text, and Obsidian note properties after sanitization
if (sanitizedRecord.typeName === 'shape') {
if (sanitizedRecord.type === 'geo' && (sanitizedRecord.props as any)?.richText) {
console.log(`🔍 TLStoreToAutomerge: Geo shape ${sanitizedRecord.id} has richText after sanitization:`, {
hasRichText: !!(sanitizedRecord.props as any).richText,
richTextType: typeof (sanitizedRecord.props as any).richText,
richTextContent: Array.isArray((sanitizedRecord.props as any).richText) ? 'array' : (sanitizedRecord.props as any).richText?.content ? 'object with content' : 'object without content'
})
}
if (sanitizedRecord.type === 'geo' && (sanitizedRecord.meta as any)?.text !== undefined) {
console.log(`🔍 TLStoreToAutomerge: Geo shape ${sanitizedRecord.id} has meta.text after sanitization:`, {
hasMetaText: !!(sanitizedRecord.meta as any).text,
metaTextValue: (sanitizedRecord.meta as any).text,
metaTextType: typeof (sanitizedRecord.meta as any).text
})
}
if (sanitizedRecord.type === 'note' && (sanitizedRecord.props as any)?.richText) {
console.log(`🔍 TLStoreToAutomerge: Note shape ${sanitizedRecord.id} has richText after sanitization:`, {
hasRichText: !!(sanitizedRecord.props as any).richText,
richTextType: typeof (sanitizedRecord.props as any).richText,
richTextContent: Array.isArray((sanitizedRecord.props as any).richText) ? 'array' : (sanitizedRecord.props as any).richText?.content ? 'object with content' : 'object without content',
richTextContentLength: Array.isArray((sanitizedRecord.props as any).richText?.content) ? (sanitizedRecord.props as any).richText.content.length : 'not array'
})
}
if (sanitizedRecord.type === 'arrow' && (sanitizedRecord.props as any)?.text !== undefined) {
console.log(`🔍 TLStoreToAutomerge: Arrow shape ${sanitizedRecord.id} has text after sanitization:`, {
hasText: !!(sanitizedRecord.props as any).text,
textValue: (sanitizedRecord.props as any).text,
textType: typeof (sanitizedRecord.props as any).text
})
}
if (sanitizedRecord.type === 'ObsNote') {
console.log(`🔍 TLStoreToAutomerge: ObsNote shape ${sanitizedRecord.id} after sanitization:`, {
hasTitle: !!(sanitizedRecord.props as any).title,
hasContent: !!(sanitizedRecord.props as any).content,
hasTags: Array.isArray((sanitizedRecord.props as any).tags),
title: (sanitizedRecord.props as any).title,
contentLength: (sanitizedRecord.props as any).content?.length || 0,
tagsCount: Array.isArray((sanitizedRecord.props as any).tags) ? (sanitizedRecord.props as any).tags.length : 0
})
}
}
// CRITICAL: Create a deep copy to ensure all properties (including richText and text) are preserved
// This prevents Automerge from treating the object as read-only
// Note: sanitizedRecord.props is already a deep copy from sanitizeRecord, but we need to deep copy the entire record
const recordToSave = JSON.parse(JSON.stringify(sanitizedRecord))
// DEBUG: Log richText, meta.text, and Obsidian note properties after deep copy
if (recordToSave.typeName === 'shape') {
if (recordToSave.type === 'geo' && recordToSave.props?.richText) {
console.log(`🔍 TLStoreToAutomerge: Geo shape ${recordToSave.id} has richText after deep copy:`, {
hasRichText: !!recordToSave.props.richText,
richTextType: typeof recordToSave.props.richText,
richTextContent: Array.isArray(recordToSave.props.richText) ? 'array' : recordToSave.props.richText?.content ? 'object with content' : 'object without content',
richTextContentLength: Array.isArray(recordToSave.props.richText?.content) ? recordToSave.props.richText.content.length : 'not array'
})
}
if (recordToSave.type === 'geo' && recordToSave.meta?.text !== undefined) {
console.log(`🔍 TLStoreToAutomerge: Geo shape ${recordToSave.id} has meta.text after deep copy:`, {
hasMetaText: !!recordToSave.meta.text,
metaTextValue: recordToSave.meta.text,
metaTextType: typeof recordToSave.meta.text
})
}
if (recordToSave.type === 'note' && recordToSave.props?.richText) {
console.log(`🔍 TLStoreToAutomerge: Note shape ${recordToSave.id} has richText after deep copy:`, {
hasRichText: !!recordToSave.props.richText,
richTextType: typeof recordToSave.props.richText,
richTextContent: Array.isArray(recordToSave.props.richText) ? 'array' : recordToSave.props.richText?.content ? 'object with content' : 'object without content',
richTextContentLength: Array.isArray(recordToSave.props.richText?.content) ? recordToSave.props.richText.content.length : 'not array'
})
}
if (recordToSave.type === 'arrow' && recordToSave.props?.text !== undefined) {
console.log(`🔍 TLStoreToAutomerge: Arrow shape ${recordToSave.id} has text after deep copy:`, {
hasText: !!recordToSave.props.text,
textValue: recordToSave.props.text,
textType: typeof recordToSave.props.text
})
}
if (recordToSave.type === 'ObsNote') {
console.log(`🔍 TLStoreToAutomerge: ObsNote shape ${recordToSave.id} after deep copy:`, {
hasTitle: !!recordToSave.props.title,
hasContent: !!recordToSave.props.content,
hasTags: Array.isArray(recordToSave.props.tags),
title: recordToSave.props.title,
contentLength: recordToSave.props.content?.length || 0,
tagsCount: Array.isArray(recordToSave.props.tags) ? recordToSave.props.tags.length : 0,
allPropsKeys: Object.keys(recordToSave.props || {})
})
}
}
// Replace the entire record - Automerge will handle merging with concurrent changes
doc.store[record.id] = recordToSave
})

View File

@ -115,7 +115,6 @@ export async function saveDocumentId(roomId: string, documentId: string): Promis
}
request.onsuccess = () => {
console.log(`Saved document mapping: ${roomId} -> ${documentId}`)
resolve()
}
})
@ -171,7 +170,6 @@ export async function deleteDocumentMapping(roomId: string): Promise<void> {
}
request.onsuccess = () => {
console.log(`Deleted document mapping for: ${roomId}`)
resolve()
}
})
@ -238,7 +236,6 @@ export async function cleanupOldMappings(maxAgeDays: number = 30): Promise<numbe
deletedCount++
cursor.continue()
} else {
console.log(`Cleaned up ${deletedCount} old document mappings`)
resolve(deletedCount)
}
}

View File

@ -116,6 +116,7 @@ import { VideoChatShape } from "@/shapes/VideoChatShapeUtil"
import { EmbedShape } from "@/shapes/EmbedShapeUtil"
import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil"
import { MycroZineGeneratorShape } from "@/shapes/MycroZineGeneratorShapeUtil"
import { SlideShape } from "@/shapes/SlideShapeUtil"
import { PromptShape } from "@/shapes/PromptShapeUtil"
import { TranscriptionShape } from "@/shapes/TranscriptionShapeUtil"
@ -129,16 +130,29 @@ import { VideoGenShape } from "@/shapes/VideoGenShapeUtil"
import { MultmuxShape } from "@/shapes/MultmuxShapeUtil"
// MycelialIntelligence moved to permanent UI bar - shape kept for backwards compatibility
import { MycelialIntelligenceShape } from "@/shapes/MycelialIntelligenceShapeUtil"
// Location shape removed - no longer needed
// Open Mapping - OSM map shape for geographic visualization
import { MapShape } from "@/shapes/MapShapeUtil"
// Calendar shape for calendar functionality
import { CalendarShape } from "@/shapes/CalendarShapeUtil"
import { CalendarEventShape } from "@/shapes/CalendarEventShapeUtil"
// Drawfast shape for quick drawing/sketching
import { DrawfastShape } from "@/shapes/DrawfastShapeUtil"
// Additional shapes from Board.tsx
import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil"
import { PrivateWorkspaceShape } from "@/shapes/PrivateWorkspaceShapeUtil"
import { GoogleItemShape } from "@/shapes/GoogleItemShapeUtil"
import { WorkflowBlockShape } from "@/shapes/WorkflowBlockShapeUtil"
export function useAutomergeStoreV2({
handle,
userId: _userId,
adapter,
isNetworkOnline = true,
}: {
handle: DocHandle<any>
userId: string
adapter?: any
isNetworkOnline?: boolean
}): TLStoreWithStatus {
// useAutomergeStoreV2 initializing
@ -151,6 +165,7 @@ export function useAutomergeStoreV2({
EmbedShape,
MarkdownShape,
MycrozineTemplateShape,
MycroZineGeneratorShape,
SlideShape,
PromptShape,
TranscriptionShape,
@ -163,6 +178,14 @@ export function useAutomergeStoreV2({
VideoGenShape,
MultmuxShape,
MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility
MapShape, // Open Mapping - OSM map shape
CalendarShape, // Calendar with view switching
CalendarEventShape, // Calendar individual events
DrawfastShape, // Drawfast quick sketching
HolonBrowserShape, // Holon browser
PrivateWorkspaceShape, // Private workspace for Google Export
GoogleItemShape, // Individual Google items
WorkflowBlockShape, // Workflow builder blocks
]
// CRITICAL: Explicitly list ALL custom shape types to ensure they're registered
@ -173,6 +196,7 @@ export function useAutomergeStoreV2({
'Embed',
'Markdown',
'MycrozineTemplate',
'MycroZineGenerator',
'Slide',
'Prompt',
'Transcription',
@ -185,6 +209,14 @@ export function useAutomergeStoreV2({
'VideoGen',
'Multmux',
'MycelialIntelligence', // Deprecated - kept for backwards compatibility
'Map', // Open Mapping - OSM map shape
'Calendar', // Calendar with view switching
'CalendarEvent', // Calendar individual events
'Drawfast', // Drawfast quick sketching
'HolonBrowser', // Holon browser
'PrivateWorkspace', // Private workspace for Google Export
'GoogleItem', // Individual Google items
'WorkflowBlock', // Workflow builder blocks
]
// Build schema with explicit entries for all custom shapes
@ -271,12 +303,7 @@ export function useAutomergeStoreV2({
return
}
// Broadcasting changes via JSON sync
const shapeRecords = addedOrUpdatedRecords.filter(r => r?.typeName === 'shape')
const deletedShapes = deletedRecordIds.filter(id => id.startsWith('shape:'))
if (shapeRecords.length > 0 || deletedShapes.length > 0) {
console.log(`📤 Broadcasting ${shapeRecords.length} shape changes and ${deletedShapes.length} deletions via JSON sync`)
}
// Broadcasting changes via JSON sync (logging disabled for performance)
if (adapter && typeof (adapter as any).send === 'function') {
// Send changes to other clients via the network adapter
@ -300,50 +327,23 @@ export function useAutomergeStoreV2({
// Listen for changes from Automerge and apply them to TLDraw
const automergeChangeHandler = (payload: DocHandleChangePayload<any>) => {
const patchCount = payload.patches?.length || 0
const shapePatches = payload.patches?.filter((p: any) => {
const id = p.path?.[1]
return id && typeof id === 'string' && id.startsWith('shape:')
}) || []
// Debug logging for sync issues
console.log(`🔄 automergeChangeHandler: ${patchCount} patches (${shapePatches.length} shapes), pendingLocalChanges=${pendingLocalChanges}`)
// Skip echoes of our own local changes using a counter.
// Each local handle.change() increments the counter, and each echo decrements it.
// Only process changes when counter is 0 (those are remote changes from other clients).
if (pendingLocalChanges > 0) {
console.log(`⏭️ Skipping echo (pendingLocalChanges was ${pendingLocalChanges}, now ${pendingLocalChanges - 1})`)
pendingLocalChanges--
return
}
console.log(`✅ Processing ${patchCount} patches as REMOTE changes (${shapePatches.length} shape patches)`)
try {
// Apply patches from Automerge to TLDraw store
if (payload.patches && payload.patches.length > 0) {
// Debug: Check if patches contain shapes
if (shapePatches.length > 0) {
console.log(`📥 Applying ${shapePatches.length} shape patches from remote`)
}
try {
const recordsBefore = store.allRecords()
const shapesBefore = recordsBefore.filter((r: any) => r.typeName === 'shape')
// CRITICAL: Pass Automerge document to patch handler so it can read full records
// This prevents coordinates from defaulting to 0,0 when patches create new records
const automergeDoc = handle.doc()
applyAutomergePatchesToTLStore(payload.patches, store, automergeDoc)
const recordsAfter = store.allRecords()
const shapesAfter = recordsAfter.filter((r: any) => r.typeName === 'shape')
if (shapesAfter.length !== shapesBefore.length) {
// Patches applied
}
// Patches processed successfully
} catch (patchError) {
console.error("Error applying patches batch, attempting individual patch application:", patchError)
// Try applying patches one by one to identify problematic ones
@ -379,7 +379,6 @@ export function useAutomergeStoreV2({
if (existingRecord && (existingRecord as any).typeName === 'shape' && (existingRecord as any).type === 'geo') {
const geoRecord = existingRecord as any
if (!geoRecord.props || !geoRecord.props.geo) {
console.log(`🔧 Attempting to fix geo shape ${recordId} missing props.geo`)
// This won't help with the current patch, but might help future patches
// The real fix should happen in AutomergeToTLStore sanitization
}
@ -437,7 +436,6 @@ export function useAutomergeStoreV2({
const storeShapeCount = store.allRecords().filter((r: any) => r.typeName === 'shape').length
if (docShapeCount > 0 && storeShapeCount === 0) {
console.log(`🔧 Handler set up after data was written. Manually processing ${docShapeCount} shapes that were loaded before handler was ready...`)
// Since patches were already emitted when handle.change() was called in useAutomergeSyncRepo,
// we need to manually process the data that's already in the doc
try {
@ -467,14 +465,12 @@ export function useAutomergeStoreV2({
// Filter out SharedPiano shapes since they're no longer supported
const filteredRecords = allRecords.filter((record: any) => {
if (record.typeName === 'shape' && record.type === 'SharedPiano') {
console.log(`⚠️ Filtering out deprecated SharedPiano shape: ${record.id}`)
return false
}
return true
})
if (filteredRecords.length > 0) {
console.log(`🔧 Manually applying ${filteredRecords.length} records to store (patches were missed during initial load, filtered out ${allRecords.length - filteredRecords.length} SharedPiano shapes)`)
store.mergeRemoteChanges(() => {
const pageRecords = filteredRecords.filter(r => r.typeName === 'page')
const shapeRecords = filteredRecords.filter(r => r.typeName === 'shape')
@ -482,7 +478,6 @@ export function useAutomergeStoreV2({
const recordsToAdd = [...pageRecords, ...otherRecords, ...shapeRecords]
store.put(recordsToAdd)
})
console.log(`✅ Manually applied ${filteredRecords.length} records to store`)
}
} catch (error) {
console.error(`❌ Error manually processing initial data:`, error)
@ -577,78 +572,91 @@ export function useAutomergeStoreV2({
// Track recent eraser activity to detect active eraser drags
let lastEraserActivity = 0
let eraserToolSelected = false
let lastEraserCheckTime = 0
let cachedEraserActive = false
const ERASER_ACTIVITY_THRESHOLD = 2000 // Increased to 2 seconds to handle longer eraser drags
const ERASER_CHECK_CACHE_MS = 100 // Only refresh eraser state every 100ms to avoid expensive checks
let eraserChangeQueue: RecordsDiff<TLRecord> | null = null
let eraserCheckInterval: NodeJS.Timeout | null = null
// Helper to check if eraser tool is actively erasing (to prevent saves during eraser drag)
// OPTIMIZED: Uses cached state and only refreshes periodically to avoid expensive store.allRecords() calls
const isEraserActive = (): boolean => {
const now = Date.now()
// Use cached result if checked recently
if (now - lastEraserCheckTime < ERASER_CHECK_CACHE_MS) {
return cachedEraserActive
}
lastEraserCheckTime = now
// If eraser was selected and recent activity, assume still active
if (eraserToolSelected && now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
cachedEraserActive = true
return true
}
// If no recent eraser activity and not marked as selected, quickly return false
if (!eraserToolSelected && now - lastEraserActivity > ERASER_ACTIVITY_THRESHOLD) {
cachedEraserActive = false
return false
}
// Only do expensive check if eraser might be transitioning
try {
const allRecords = store.allRecords()
// Use store.get() for specific records instead of allRecords() for better performance
const instancePageState = store.get('instance_page_state:page:page' as any)
// Check instance_page_state for erasingShapeIds (most reliable indicator)
const instancePageState = allRecords.find((r: any) =>
r.typeName === 'instance_page_state' &&
(r as any).erasingShapeIds &&
Array.isArray((r as any).erasingShapeIds) &&
(r as any).erasingShapeIds.length > 0
)
if (instancePageState) {
lastEraserActivity = Date.now()
if (instancePageState &&
(instancePageState as any).erasingShapeIds &&
Array.isArray((instancePageState as any).erasingShapeIds) &&
(instancePageState as any).erasingShapeIds.length > 0) {
lastEraserActivity = now
eraserToolSelected = true
cachedEraserActive = true
return true // Eraser is actively erasing shapes
}
// Check if eraser tool is selected
const instance = allRecords.find((r: any) => r.typeName === 'instance')
const instance = store.get('instance:instance' as any)
const currentToolId = instance ? (instance as any).currentToolId : null
if (currentToolId === 'eraser') {
eraserToolSelected = true
const now = Date.now()
// If eraser tool is selected, keep it active for longer to handle drags
// Also check if there was recent activity
if (now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
return true
}
// If tool is selected but no recent activity, still consider it active
// (user might be mid-drag)
lastEraserActivity = now
cachedEraserActive = true
return true
} else {
// Tool switched away - only consider active if very recent activity
eraserToolSelected = false
const now = Date.now()
if (now - lastEraserActivity < 300) {
return true // Very recent activity, might still be processing
}
}
cachedEraserActive = false
return false
} catch (e) {
// If we can't check, use last known state with timeout
const now = Date.now()
if (eraserToolSelected && now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
cachedEraserActive = true
return true
}
cachedEraserActive = false
return false
}
}
// Track eraser activity from shape deletions
// OPTIMIZED: Only check for eraser tool when shapes are removed, and use cached tool state
const checkForEraserActivity = (changes: RecordsDiff<TLRecord>) => {
// If shapes are being removed and eraser tool might be active, mark activity
if (changes.removed) {
const removedShapes = Object.values(changes.removed).filter((r: any) =>
r && r.typeName === 'shape'
)
if (removedShapes.length > 0) {
// Check if eraser tool is currently selected
const allRecords = store.allRecords()
const instance = allRecords.find((r: any) => r.typeName === 'instance')
if (instance && (instance as any).currentToolId === 'eraser') {
lastEraserActivity = Date.now()
eraserToolSelected = true
const removedKeys = Object.keys(changes.removed)
// Quick check: if no shape keys, skip
const hasRemovedShapes = removedKeys.some(key => key.startsWith('shape:'))
if (hasRemovedShapes) {
// Use cached eraserToolSelected state if recent, avoid expensive allRecords() call
const now = Date.now()
if (eraserToolSelected || now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
lastEraserActivity = now
}
}
}
@ -685,17 +693,6 @@ export function useAutomergeStoreV2({
id.startsWith('pointer:')
)
// DEBUG: Log why records are being filtered or not
const shouldFilter = (typeName && ephemeralTypes.includes(typeName)) || idMatchesEphemeral
if (shouldFilter) {
console.log(`🚫 Filtering out ephemeral record:`, {
id,
typeName,
idMatchesEphemeral,
typeNameMatches: typeName && ephemeralTypes.includes(typeName)
})
}
// Filter out if typeName matches OR if ID pattern matches ephemeral types
if (typeName && ephemeralTypes.includes(typeName)) {
// Skip - this is an ephemeral record
@ -718,183 +715,9 @@ export function useAutomergeStoreV2({
removed: filterEphemeral(changes.removed),
}
// DEBUG: Log all changes to see what's being detected
const totalChanges = Object.keys(changes.added || {}).length + Object.keys(changes.updated || {}).length + Object.keys(changes.removed || {}).length
// Calculate change counts (minimal, needed for early return)
const filteredTotalChanges = Object.keys(filteredChanges.added || {}).length + Object.keys(filteredChanges.updated || {}).length + Object.keys(filteredChanges.removed || {}).length
// DEBUG: Log ALL changes (before filtering) to see what's actually being updated
if (totalChanges > 0) {
const allChangedRecords: Array<{id: string, typeName: string, changeType: string}> = []
if (changes.added) {
Object.entries(changes.added).forEach(([id, record]: [string, any]) => {
const recordObj = Array.isArray(record) ? record[1] : record
allChangedRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'added' })
})
}
if (changes.updated) {
Object.entries(changes.updated).forEach(([id, [_, record]]: [string, [any, any]]) => {
allChangedRecords.push({ id, typeName: record?.typeName || 'unknown', changeType: 'updated' })
})
}
if (changes.removed) {
Object.entries(changes.removed).forEach(([id, record]: [string, any]) => {
const recordObj = Array.isArray(record) ? record[1] : record
allChangedRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'removed' })
})
}
console.log(`🔍 ALL changes detected (before filtering):`, {
total: totalChanges,
records: allChangedRecords,
// Also log the actual record objects to see their structure
recordDetails: allChangedRecords.map(r => {
let record: any = null
if (r.changeType === 'added' && changes.added) {
const rec = (changes.added as any)[r.id]
record = Array.isArray(rec) ? rec[1] : rec
} else if (r.changeType === 'updated' && changes.updated) {
const rec = (changes.updated as any)[r.id]
record = Array.isArray(rec) ? rec[1] : rec
} else if (r.changeType === 'removed' && changes.removed) {
const rec = (changes.removed as any)[r.id]
record = Array.isArray(rec) ? rec[1] : rec
}
return {
id: r.id,
typeName: r.typeName,
changeType: r.changeType,
hasTypeName: !!record?.typeName,
actualTypeName: record?.typeName,
recordKeys: record ? Object.keys(record).slice(0, 10) : []
}
})
})
}
// Log if we filtered out any ephemeral changes
if (totalChanges > 0 && filteredTotalChanges < totalChanges) {
const filteredCount = totalChanges - filteredTotalChanges
const filteredTypes = new Set<string>()
const filteredIds: string[] = []
if (changes.added) {
Object.entries(changes.added).forEach(([id, record]: [string, any]) => {
const recordObj = Array.isArray(record) ? record[1] : record
if (recordObj && ephemeralTypes.includes(recordObj.typeName)) {
filteredTypes.add(recordObj.typeName)
filteredIds.push(id)
}
})
}
if (changes.updated) {
Object.entries(changes.updated).forEach(([id, [_, record]]: [string, [any, any]]) => {
if (ephemeralTypes.includes(record.typeName)) {
filteredTypes.add(record.typeName)
filteredIds.push(id)
}
})
}
if (changes.removed) {
Object.entries(changes.removed).forEach(([id, record]: [string, any]) => {
const recordObj = Array.isArray(record) ? record[1] : record
if (recordObj && ephemeralTypes.includes(recordObj.typeName)) {
filteredTypes.add(recordObj.typeName)
filteredIds.push(id)
}
})
}
console.log(`🚫 Filtered out ${filteredCount} ephemeral change(s) (${Array.from(filteredTypes).join(', ')}) - not persisting`, {
filteredIds: filteredIds.slice(0, 5), // Show first 5 IDs
totalFiltered: filteredIds.length
})
}
if (filteredTotalChanges > 0) {
// Log what records are passing through the filter (shouldn't happen for ephemeral records)
const passingRecords: Array<{id: string, typeName: string, changeType: string}> = []
if (filteredChanges.added) {
Object.entries(filteredChanges.added).forEach(([id, record]: [string, any]) => {
const recordObj = Array.isArray(record) ? record[1] : record
passingRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'added' })
})
}
if (filteredChanges.updated) {
Object.entries(filteredChanges.updated).forEach(([id, recordTuple]: [string, any]) => {
const record = Array.isArray(recordTuple) && recordTuple.length === 2 ? recordTuple[1] : recordTuple
passingRecords.push({ id, typeName: (record as any)?.typeName || 'unknown', changeType: 'updated' })
})
}
if (filteredChanges.removed) {
Object.entries(filteredChanges.removed).forEach(([id, record]: [string, any]) => {
const recordObj = Array.isArray(record) ? record[1] : record
passingRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'removed' })
})
}
console.log(`🔍 TLDraw store changes detected (source: ${source}):`, {
added: Object.keys(filteredChanges.added || {}).length,
updated: Object.keys(filteredChanges.updated || {}).length,
removed: Object.keys(filteredChanges.removed || {}).length,
source: source,
passingRecords: passingRecords // Show what's actually passing through
})
// DEBUG: Check for richText/text changes in updated records
if (filteredChanges.updated) {
Object.values(filteredChanges.updated).forEach((recordTuple: any) => {
const record = Array.isArray(recordTuple) && recordTuple.length === 2 ? recordTuple[1] : recordTuple
if ((record as any)?.typeName === 'shape') {
const rec = record as any
if (rec.type === 'geo' && rec.props?.richText) {
console.log(`🔍 Geo shape ${rec.id} richText change detected:`, {
hasRichText: !!rec.props.richText,
richTextType: typeof rec.props.richText,
source: source
})
}
if (rec.type === 'note' && rec.props?.richText) {
console.log(`🔍 Note shape ${rec.id} richText change detected:`, {
hasRichText: !!rec.props.richText,
richTextType: typeof rec.props.richText,
richTextContentLength: Array.isArray(rec.props.richText?.content)
? rec.props.richText.content.length
: 'not array',
source: source
})
}
if (rec.type === 'arrow' && rec.props?.text !== undefined) {
console.log(`🔍 Arrow shape ${rec.id} text change detected:`, {
hasText: !!rec.props.text,
textValue: rec.props.text,
source: source
})
}
if (rec.type === 'text' && rec.props?.richText) {
console.log(`🔍 Text shape ${rec.id} richText change detected:`, {
hasRichText: !!rec.props.richText,
richTextType: typeof rec.props.richText,
source: source
})
}
}
})
}
// DEBUG: Log added shapes to track what's being created
if (filteredChanges.added) {
Object.values(filteredChanges.added).forEach((record: any) => {
const rec = Array.isArray(record) ? record[1] : record
if (rec?.typeName === 'shape') {
console.log(`🔍 Shape added: ${rec.type} (${rec.id})`, {
type: rec.type,
id: rec.id,
hasRichText: !!rec.props?.richText,
hasText: !!rec.props?.text,
source: source
})
}
})
}
}
// Skip if no meaningful changes after filtering ephemeral records
if (filteredTotalChanges === 0) {
return
@ -903,7 +726,6 @@ export function useAutomergeStoreV2({
// CRITICAL: Skip broadcasting changes that came from remote sources to prevent feedback loops
// Only broadcast changes that originated from user interactions (source === 'user')
if (source === 'remote') {
console.log('🔄 Skipping broadcast for remote change to prevent feedback loop')
return
}
@ -996,7 +818,6 @@ export function useAutomergeStoreV2({
// If only position changed (x/y), restore original coordinates
if (!otherPropsChanged && (newX !== originalX || newY !== originalY)) {
console.log(`🚫 Filtering out x/y coordinate change for pinned shape ${id}: (${newX}, ${newY}) -> keeping original (${originalX}, ${originalY})`)
// Restore original coordinates
const recordWithOriginalCoords = {
...record,
@ -1041,38 +862,6 @@ export function useAutomergeStoreV2({
// Check if this is a position-only update that should be throttled
const isPositionOnly = isPositionOnlyUpdate(finalFilteredChanges)
// Log what type of change this is for debugging
const changeType = Object.keys(finalFilteredChanges.added || {}).length > 0 ? 'added' :
Object.keys(finalFilteredChanges.removed || {}).length > 0 ? 'removed' :
isPositionOnly ? 'position-only' : 'property-change'
// DEBUG: Log dimension changes for shapes
if (finalFilteredChanges.updated) {
Object.entries(finalFilteredChanges.updated).forEach(([id, recordTuple]: [string, any]) => {
const isTuple = Array.isArray(recordTuple) && recordTuple.length === 2
const oldRecord = isTuple ? recordTuple[0] : null
const newRecord = isTuple ? recordTuple[1] : recordTuple
if (newRecord?.typeName === 'shape') {
const oldProps = oldRecord?.props || {}
const newProps = newRecord?.props || {}
if (oldProps.w !== newProps.w || oldProps.h !== newProps.h) {
console.log(`🔍 Shape dimension change detected for ${newRecord.type} ${id}:`, {
oldDims: { w: oldProps.w, h: oldProps.h },
newDims: { w: newProps.w, h: newProps.h },
source
})
}
}
})
}
console.log(`🔍 Change detected: ${changeType}, will ${isPositionOnly ? 'throttle' : 'broadcast immediately'}`, {
added: Object.keys(finalFilteredChanges.added || {}).length,
updated: Object.keys(finalFilteredChanges.updated || {}).length,
removed: Object.keys(finalFilteredChanges.removed || {}).length,
source
})
if (isPositionOnly && positionUpdateQueue === null) {
// Start a new queue for position updates
positionUpdateQueue = finalFilteredChanges
@ -1255,12 +1044,7 @@ export function useAutomergeStoreV2({
broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds)
}
// Only log if there are many changes or if debugging is needed
if (filteredTotalChanges > 3) {
console.log(`✅ Applied ${filteredTotalChanges} TLDraw changes to Automerge document`)
} else if (filteredTotalChanges > 0) {
console.log(`✅ Applied ${filteredTotalChanges} TLDraw change(s) to Automerge document`)
}
// Logging disabled for performance during continuous drawing
// Check if the document actually changed
const docAfter = handle.doc()
@ -1313,58 +1097,115 @@ export function useAutomergeStoreV2({
try {
await handle.whenReady()
const doc = handle.doc()
// Check if store is already populated from patches
const existingStoreRecords = store.allRecords()
const existingStoreShapes = existingStoreRecords.filter((r: any) => r.typeName === 'shape')
// Determine connection status based on network state
const connectionStatus = isNetworkOnline ? "online" : "offline"
if (doc.store) {
const storeKeys = Object.keys(doc.store)
const docShapes = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
console.log(`📊 Patch-based initialization: doc has ${storeKeys.length} records (${docShapes} shapes), store has ${existingStoreRecords.length} records (${existingStoreShapes.length} shapes)`)
// If store already has shapes, patches have been applied (dev mode behavior)
if (existingStoreShapes.length > 0) {
console.log(`✅ Store already populated from patches (${existingStoreShapes.length} shapes) - using patch-based loading like dev`)
// REMOVED: Aggressive shape refresh that was causing coordinate loss
// Shapes should be visible through normal patch application
// If shapes aren't visible, it's likely a different issue that refresh won't fix
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
connectionStatus,
})
return
}
// OFFLINE FAST PATH: When offline with local data, load immediately
// Don't wait for patches that will never come from the network
if (!isNetworkOnline && docShapes > 0) {
// Manually load data from Automerge doc since patches won't come through
try {
const allRecords: TLRecord[] = []
Object.entries(doc.store).forEach(([id, record]: [string, any]) => {
if (!record || !record.typeName || !record.id) return
if (record.typeName === 'obsidian_vault' || (typeof record.id === 'string' && record.id.startsWith('obsidian_vault:'))) return
try {
let cleanRecord: any
try {
cleanRecord = JSON.parse(JSON.stringify(record))
} catch {
cleanRecord = safeExtractPlainObject(record)
}
if (cleanRecord && typeof cleanRecord === 'object') {
const sanitized = sanitizeRecord(cleanRecord)
const plainSanitized = JSON.parse(JSON.stringify(sanitized))
allRecords.push(plainSanitized)
}
} catch (e) {
console.warn(`⚠️ Could not process record ${id}:`, e)
}
})
// Filter out SharedPiano shapes since they're no longer supported
const filteredRecords = allRecords.filter((record: any) => {
if (record.typeName === 'shape' && record.type === 'SharedPiano') {
return false
}
return true
})
if (filteredRecords.length > 0) {
store.mergeRemoteChanges(() => {
const pageRecords = filteredRecords.filter(r => r.typeName === 'page')
const shapeRecords = filteredRecords.filter(r => r.typeName === 'shape')
const otherRecords = filteredRecords.filter(r => r.typeName !== 'page' && r.typeName !== 'shape')
const recordsToAdd = [...pageRecords, ...otherRecords, ...shapeRecords]
store.put(recordsToAdd)
})
}
} catch (error) {
console.error(`❌ Error loading offline data:`, error)
}
setStoreWithStatus({
store,
status: "synced-remote", // Use synced-remote so Board renders
connectionStatus: "offline",
})
return
}
// If doc has data but store doesn't, patches should have been generated when data was written
// The automergeChangeHandler (set up above) should process them automatically
// Just wait a bit for patches to be processed, then set status
if (docShapes > 0 && existingStoreShapes.length === 0) {
console.log(`📊 Doc has ${docShapes} shapes but store is empty. Waiting for patches to be processed by handler...`)
// Wait briefly for patches to be processed by automergeChangeHandler
// The handler is already set up, so it should catch patches from the initial data load
let attempts = 0
const maxAttempts = 10 // Wait up to 2 seconds (10 * 200ms)
await new Promise<void>(resolve => {
const checkForPatches = () => {
attempts++
const currentShapes = store.allRecords().filter((r: any) => r.typeName === 'shape')
if (currentShapes.length > 0) {
console.log(`✅ Patches applied successfully: ${currentShapes.length} shapes loaded via patches`)
// REMOVED: Aggressive shape refresh that was causing coordinate loss
// Shapes loaded via patches should be visible without forced refresh
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
connectionStatus,
})
resolve()
} else if (attempts < maxAttempts) {
@ -1375,45 +1216,43 @@ export function useAutomergeStoreV2({
console.warn(`⚠️ No patches received after ${maxAttempts} attempts for room initialization.`)
console.warn(`⚠️ This may happen if Automerge doc was initialized with server data before handler was ready.`)
console.warn(`⚠️ Store will remain empty - patches should handle data loading in normal operation.`)
// Simplified fallback: Just log and continue with empty store
// Patches should handle data loading, so if they don't come through,
// it's likely the document is actually empty or there's a timing issue
// that will resolve on next sync
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
connectionStatus,
})
resolve()
}
}
// Start checking immediately since handler is already set up
setTimeout(checkForPatches, 100)
})
return
}
// If doc is empty, just set status
if (docShapes === 0) {
console.log(`📊 Empty document - starting fresh (patch-based loading)`)
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
connectionStatus,
})
return
}
} else {
// No store in doc - empty document
console.log(`📊 No store in Automerge doc - starting fresh (patch-based loading)`)
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
connectionStatus: isNetworkOnline ? "online" : "offline",
})
return
}
@ -1422,17 +1261,17 @@ export function useAutomergeStoreV2({
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
connectionStatus: isNetworkOnline ? "online" : "offline",
})
}
}
initializeStore()
return () => {
unsubs.forEach((unsub) => unsub())
}
}, [handle, store])
}, [handle, store, isNetworkOnline])
/* -------------------- Presence -------------------- */
// Create a safe handle that won't cause null errors

View File

@ -1,6 +1,6 @@
import { useMemo, useEffect, useState, useCallback, useRef } from "react"
import { TLStoreSnapshot, InstancePresenceRecordType, getIndexAbove, IndexKey } from "@tldraw/tldraw"
import { CloudflareNetworkAdapter } from "./CloudflareAdapter"
import { CloudflareNetworkAdapter, ConnectionState } from "./CloudflareAdapter"
import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2"
import { TLStoreWithStatus } from "@tldraw/tldraw"
import { Repo, parseAutomergeUrl, stringifyAutomergeUrl, AutomergeUrl, DocumentId } from "@automerge/automerge-repo"
@ -68,7 +68,6 @@ function migrateStoreData(store: Record<string, any>): Record<string, any> {
return store
}
console.log('🔄 Migrating store data: fixing invalid shape indices')
// Copy non-shape records as-is
for (const [id, record] of nonShapes) {
@ -99,7 +98,6 @@ function migrateStoreData(store: Record<string, any>): Record<string, any> {
migratedStore[id] = migratedRecord
}
console.log(`✅ Migrated ${shapes.length} shapes with new indices`)
return migratedStore
}
@ -114,9 +112,14 @@ interface AutomergeSyncConfig {
}
}
export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus & { handle: DocHandle<any> | null; presence: ReturnType<typeof useAutomergePresence> } {
export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus & {
handle: DocHandle<any> | null;
presence: ReturnType<typeof useAutomergePresence>;
connectionState: ConnectionState;
isNetworkOnline: boolean;
} {
const { uri, user } = config
// Extract roomId from URI (e.g., "https://worker.com/connect/room123" -> "room123")
const roomId = useMemo(() => {
const match = uri.match(/\/connect\/([^\/]+)$/)
@ -130,79 +133,11 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
const [handle, setHandle] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true)
const [connectionState, setConnectionState] = useState<ConnectionState>('connecting')
const [isNetworkOnline, setIsNetworkOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true)
const handleRef = useRef<any>(null)
const storeRef = useRef<any>(null)
const adapterRef = 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
// Also include text content for shapes that have it (Markdown, ObsNote, etc.)
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}`
// CRITICAL: Include text content in hash for Markdown and similar shapes
// This ensures text changes trigger R2 persistence
if (typeof record.props?.text === 'string' && record.props.text.length > 0) {
// Include text length and a sample of content for change detection
recordHash += `t${record.props.text.length}`
// Include first 100 chars and last 50 chars to detect changes anywhere in the text
recordHash += record.props.text.substring(0, 100)
if (record.props.text.length > 150) {
recordHash += record.props.text.substring(record.props.text.length - 50)
}
}
// Also include content for ObsNote shapes
if (typeof record.props?.content === 'string' && record.props.content.length > 0) {
recordHash += `c${record.props.content.length}`
recordHash += record.props.content.substring(0, 100)
if (record.props.content.length > 150) {
recordHash += record.props.content.substring(record.props.content.length - 50)
}
}
}
// 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(() => {
@ -223,22 +158,6 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
const deletedRecordIds = data.deleted || []
const deletedShapes = deletedRecordIds.filter(id => id.startsWith('shape:'))
// Log incoming sync data for debugging
console.log(`📥 Received JSON sync: ${changedRecordCount} records (${shapeRecords.length} shapes), ${deletedRecordIds.length} deletions (${deletedShapes.length} shapes)`)
if (shapeRecords.length > 0) {
shapeRecords.forEach((shape: any) => {
console.log(`📥 Shape update: ${shape.type} ${shape.id}`, {
x: shape.x,
y: shape.y,
w: shape.props?.w,
h: shape.props?.h
})
})
}
if (deletedShapes.length > 0) {
console.log(`📥 Shape deletions:`, deletedShapes)
}
// Apply changes to the Automerge document
// This will trigger patches which will update the TLDraw store
// NOTE: We do NOT increment pendingLocalChanges here because these are REMOTE changes
@ -263,7 +182,31 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
}
})
console.log(`✅ Applied ${changedRecordCount} records and ${deletedRecordIds.length} deletions to Automerge document`)
}, [])
// Presence update batching to prevent "Maximum update depth exceeded" errors
// We batch presence updates and apply them in a single mergeRemoteChanges call
const pendingPresenceUpdates = useRef<Map<string, any>>(new Map())
const presenceUpdateTimer = useRef<NodeJS.Timeout | null>(null)
const PRESENCE_BATCH_INTERVAL_MS = 16 // ~60fps, batch updates every frame
// Flush pending presence updates to the store
const flushPresenceUpdates = useCallback(() => {
const currentStore = storeRef.current
if (!currentStore || pendingPresenceUpdates.current.size === 0) {
return
}
const updates = Array.from(pendingPresenceUpdates.current.values())
pendingPresenceUpdates.current.clear()
try {
currentStore.mergeRemoteChanges(() => {
currentStore.put(updates)
})
} catch (error) {
console.error('❌ Error flushing presence updates:', error)
}
}, [])
// Presence update callback - applies presence from other clients
@ -319,23 +262,54 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
lastActivityTimestamp: Date.now()
})
// Apply the instance_presence record using mergeRemoteChanges for atomic updates
currentStore.mergeRemoteChanges(() => {
currentStore.put([instancePresence])
})
// Queue the presence update for batched application
pendingPresenceUpdates.current.set(presenceId, instancePresence)
// Schedule a flush if not already scheduled
if (!presenceUpdateTimer.current) {
presenceUpdateTimer.current = setTimeout(() => {
presenceUpdateTimer.current = null
flushPresenceUpdates()
}, PRESENCE_BATCH_INTERVAL_MS)
}
// Presence applied for remote user
} catch (error) {
console.error('❌ Error applying presence:', error)
}
}, [flushPresenceUpdates])
// Handle presence leave - remove the user's presence record from the store
const handlePresenceLeave = useCallback((sessionId: string) => {
const currentStore = storeRef.current
if (!currentStore) return
try {
// Find and remove the presence record for this session
// Presence IDs are formatted as "instance_presence:{sessionId}"
const presenceId = `instance_presence:${sessionId}`
// Check if this record exists before trying to remove it
const allRecords = currentStore.allRecords()
const presenceRecord = allRecords.find((r: any) =>
r.id === presenceId ||
r.id?.includes(sessionId)
)
if (presenceRecord) {
currentStore.remove([presenceRecord.id])
}
} catch (error) {
console.error('Error removing presence on leave:', error)
}
}, [])
const { repo, adapter, storageAdapter } = useMemo(() => {
const adapter = new CloudflareNetworkAdapter(
workerUrl,
roomId,
applyJsonSyncData,
applyPresenceUpdate
applyPresenceUpdate,
handlePresenceLeave
)
// Store adapter ref for use in callbacks
@ -358,7 +332,16 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
})
return { repo, adapter, storageAdapter }
}, [workerUrl, roomId, applyJsonSyncData, applyPresenceUpdate])
}, [workerUrl, roomId, applyJsonSyncData, applyPresenceUpdate, handlePresenceLeave])
// Subscribe to connection state changes
useEffect(() => {
const unsubscribe = adapter.onConnectionStateChange((state) => {
setConnectionState(state)
setIsNetworkOnline(adapter.isNetworkOnline)
})
return unsubscribe
}, [adapter])
// Initialize Automerge document handle
useEffect(() => {
@ -366,11 +349,8 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
const initializeHandle = async () => {
try {
// CRITICAL: Wait for the network adapter to be ready before creating document
// This ensures the WebSocket connection is established for sync
await adapter.whenReady()
if (!mounted) return
// OFFLINE-FIRST: Load from IndexedDB immediately, don't wait for network
// Network sync happens in the background after local data is loaded
let handle: DocHandle<TLStoreSnapshot>
let loadedFromLocal = false
@ -380,7 +360,6 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
const storedDocumentId = await getDocumentId(roomId)
if (storedDocumentId) {
console.log(`Found stored document ID for room ${roomId}: ${storedDocumentId}`)
try {
// Parse the URL to get the DocumentId
const parsed = parseAutomergeUrl(storedDocumentId as AutomergeUrl)
@ -392,7 +371,6 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
let foundHandle: DocHandle<TLStoreSnapshot>
if (existingHandle) {
console.log(`Document ${docId} already in repo cache, reusing handle`)
foundHandle = existingHandle
} else {
// Try to find the existing document in the repo (loads from IndexedDB)
@ -408,14 +386,12 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
const localShapeCount = localDoc?.store ? Object.values(localDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
if (localRecordCount > 0) {
console.log(`Loaded document from IndexedDB: ${localRecordCount} records, ${localShapeCount} shapes`)
// CRITICAL: Migrate local IndexedDB data to fix any invalid indices
// This ensures shapes with old-format indices like "b1" are fixed
if (localDoc?.store) {
const migratedStore = migrateStoreData(localDoc.store)
if (migratedStore !== localDoc.store) {
console.log('🔄 Applying index migration to local IndexedDB data')
handle.change((doc: any) => {
doc.store = migratedStore
})
@ -424,7 +400,6 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
loadedFromLocal = true
} else {
console.log(`Document found in IndexedDB but is empty, will load from server`)
}
} catch (error) {
console.warn(`Failed to load document ${storedDocumentId} from IndexedDB:`, error)
@ -434,7 +409,6 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
// If we didn't load from local storage, create a new document
if (!loadedFromLocal || !handle!) {
console.log(`Creating new Automerge document for room ${roomId}`)
handle = repo.create<TLStoreSnapshot>()
await handle.whenReady()
@ -442,86 +416,139 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
const documentId = handle.url
if (documentId) {
await saveDocumentId(roomId, documentId)
console.log(`Saved new document mapping: ${roomId} -> ${documentId}`)
}
}
if (!mounted) return
// Sync with server to get latest data (or upload local changes if offline was edited)
// This ensures we're in sync even if we loaded from IndexedDB
try {
const response = await fetch(`${workerUrl}/room/${roomId}`)
if (response.ok) {
let serverDoc = await response.json() as TLStoreSnapshot
// OFFLINE-FIRST: Set the handle and mark as ready BEFORE network sync
// This allows the UI to render immediately with local data
if (handle.url) {
adapter.setDocumentId(handle.url)
}
// Migrate server data to fix any invalid indices
if (serverDoc.store) {
serverDoc = {
...serverDoc,
store: migrateStoreData(serverDoc.store)
// If we loaded from local, set handle immediately so UI can render
if (loadedFromLocal) {
const localDoc = handle.doc() as any
const localShapeCount = localDoc?.store ? Object.values(localDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
setHandle(handle)
setIsLoading(false)
}
// Sync with server in the background (non-blocking for offline-first)
// This runs in parallel - if it fails, we still have local data
const syncWithServer = async () => {
try {
// Wait for network adapter with a timeout
const networkReadyPromise = adapter.whenReady()
const timeoutPromise = new Promise<'timeout'>((resolve) =>
setTimeout(() => resolve('timeout'), 5000)
)
const result = await Promise.race([networkReadyPromise, timeoutPromise])
if (result === 'timeout') {
// If we haven't set the handle yet (no local data), set it now
if (!loadedFromLocal && mounted) {
setHandle(handle)
setIsLoading(false)
}
return
}
const serverShapeCount = serverDoc.store ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
const serverRecordCount = Object.keys(serverDoc.store || {}).length
if (!mounted) return
// Get current local state
const localDoc = handle.doc()
const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
const response = await fetch(`${workerUrl}/room/${roomId}`)
if (response.ok) {
let serverDoc = await response.json() as TLStoreSnapshot
// Merge server data with local data
// Automerge handles conflict resolution automatically via CRDT
if (serverDoc.store && serverRecordCount > 0) {
handle.change((doc: any) => {
// Initialize store if it doesn't exist
if (!doc.store) {
doc.store = {}
// Migrate server data to fix any invalid indices
if (serverDoc.store) {
serverDoc = {
...serverDoc,
store: migrateStoreData(serverDoc.store)
}
// Merge server records - Automerge will handle conflicts
Object.entries(serverDoc.store).forEach(([id, record]) => {
// Only add if not already present locally (local changes take precedence)
// This is a simple merge strategy - Automerge's CRDT will handle deeper conflicts
if (!doc.store[id]) {
doc.store[id] = record
}
})
})
}
const finalDoc = handle.doc()
const finalRecordCount = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
console.log(`Merged server data: server had ${serverRecordCount}, local had ${localRecordCount}, final has ${finalRecordCount} records`)
} else if (!loadedFromLocal) {
// Server is empty and we didn't load from local - fresh start
console.log(`Starting fresh - no data on server or locally`)
}
} else if (response.status === 404) {
// No document found on server
if (loadedFromLocal) {
console.log(`No server document, but loaded ${handle.doc()?.store ? Object.keys(handle.doc()!.store).length : 0} records from local storage`)
const serverShapeCount = serverDoc.store ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
const serverRecordCount = Object.keys(serverDoc.store || {}).length
// Get current local state
const localDoc = handle.doc()
const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
// Merge server data with local data
// Strategy:
// 1. If local is EMPTY, use server data (bootstrap from R2)
// 2. If local HAS data, only add server records that don't exist locally
// (preserve offline changes, let Automerge CRDT sync handle conflicts)
if (serverDoc.store && serverRecordCount > 0) {
handle.change((doc: any) => {
// Initialize store if it doesn't exist
if (!doc.store) {
doc.store = {}
}
const localIsEmpty = Object.keys(doc.store).length === 0
let addedFromServer = 0
let skippedExisting = 0
Object.entries(serverDoc.store).forEach(([id, record]) => {
if (localIsEmpty) {
// Local is empty - bootstrap everything from server
doc.store[id] = record
addedFromServer++
} else if (!doc.store[id]) {
// Local has data but missing this record - add from server
// This handles: shapes created on another device and synced to R2
doc.store[id] = record
addedFromServer++
} else {
// Record exists locally - preserve local version
// The Automerge binary sync will handle merging conflicts via CRDT
// This preserves offline edits to existing shapes
skippedExisting++
}
})
})
const finalDoc = handle.doc()
const finalRecordCount = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
} else if (!loadedFromLocal) {
// Server is empty and we didn't load from local - fresh start
}
} else if (response.status === 404) {
// No document found on server
if (loadedFromLocal) {
} else {
}
} else {
console.log(`No document found on server - starting fresh`)
console.warn(`Failed to load document from server: ${response.status} ${response.statusText}`)
}
} catch (error) {
// Network error - continue with local data if available
if (loadedFromLocal) {
} else {
console.error("Error loading from server (offline?):", error)
}
} else {
console.warn(`Failed to load document from server: ${response.status} ${response.statusText}`)
}
} catch (error) {
// Network error - continue with local data if available
if (loadedFromLocal) {
console.log(`Offline mode: using local data from IndexedDB`)
} else {
console.error("Error loading from server (offline?):", error)
// Verify final document state
const finalDoc = handle.doc() as any
const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
// If we haven't set the handle yet (no local data), set it now after server sync
if (!loadedFromLocal && mounted) {
setHandle(handle)
setIsLoading(false)
}
}
// Verify final document state
const finalDoc = handle.doc() as any
const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
console.log(`Automerge handle ready: ${finalStoreKeys} records, ${finalShapeCount} shapes (loaded from ${loadedFromLocal ? 'IndexedDB' : 'server/new'})`)
// Start server sync in background (don't await - non-blocking)
syncWithServer()
setHandle(handle)
setIsLoading(false)
} catch (error) {
console.error("Error initializing Automerge handle:", error)
if (mounted) {
@ -534,6 +561,11 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
return () => {
mounted = false
// Clear any pending presence update timer
if (presenceUpdateTimer.current) {
clearTimeout(presenceUpdateTimer.current)
presenceUpdateTimer.current = null
}
// Disconnect adapter on unmount to clean up WebSocket connection
if (adapter) {
adapter.disconnect?.()
@ -541,318 +573,49 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
}
}, [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
// BINARY CRDT SYNC: The Automerge Repo now handles sync automatically via the NetworkAdapter
// The NetworkAdapter sends binary sync messages when documents change
// Local persistence is handled by IndexedDB via the storage adapter
// Server persistence is handled by the worker receiving binary sync messages
//
// We keep a lightweight change logger for debugging, but no HTTP POST sync
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
// Listen for changes to log sync activity (debugging only)
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
if (!patchCount) return
// Filter out ephemeral record changes for logging
const ephemeralIdPatterns = [
'instance:',
'instance_page_state:',
'instance_presence:',
'camera:',
'pointer:'
]
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 (hasOnlyEphemeralChanges) {
// Don't log ephemeral changes
return
}
}
handle.on('change', changeHandler)
return () => {
handle.off('change', changeHandler)
if (saveTimeout) clearTimeout(saveTimeout)
}
}, [handle, roomId, workerUrl, generateDocHash])
}, [handle])
// Generate a unique color for each user based on their userId
const generateUserColor = (userId: string): string => {
@ -868,20 +631,26 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
}
// Get user metadata for presence
// Color is generated from the username (name) for consistency across sessions,
// not from the unique session ID (userId) which changes per tab/session
const userMetadata: { userId: string; name: string; color: string } = (() => {
if (user && 'userId' in user) {
const uid = (user as { userId: string; name: string; color?: string }).userId
const name = (user as { userId: string; name: string; color?: string }).name
return {
userId: uid,
name: (user as { userId: string; name: string; color?: string }).name,
color: (user as { userId: string; name: string; color?: string }).color || generateUserColor(uid)
name: name,
// Use name for color (consistent across sessions), fall back to uid if no name
color: (user as { userId: string; name: string; color?: string }).color || generateUserColor(name || uid)
}
}
const uid = user?.id || 'anonymous'
const name = user?.name || 'Anonymous'
return {
userId: uid,
name: user?.name || 'Anonymous',
color: generateUserColor(uid)
name: name,
// Use name for color (consistent across sessions), fall back to uid if no name
color: generateUserColor(name !== 'Anonymous' ? name : uid)
}
})()
@ -889,7 +658,8 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
const storeWithStatus = useAutomergeStoreV2({
handle: handle || null as any,
userId: userMetadata.userId,
adapter: adapter // Pass adapter for JSON sync broadcasting
adapter: adapter, // Pass adapter for JSON sync broadcasting
isNetworkOnline // Pass network state for offline support
})
// Update store ref when store is available
@ -910,6 +680,8 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
return {
...storeWithStatus,
handle,
presence
presence,
connectionState,
isNetworkOnline
}
}

View File

@ -0,0 +1,562 @@
import React, { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useParams } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { WORKER_URL } from '../constants/workerUrl';
import * as crypto from '../lib/auth/crypto';
interface BoardSettingsDropdownProps {
className?: string;
}
interface BoardInfo {
id: string;
name: string | null;
isProtected: boolean;
ownerUsername: string | null;
}
interface Editor {
userId: string;
username: string;
email: string;
permission: string;
grantedAt: string;
}
const BoardSettingsDropdown: React.FC<BoardSettingsDropdownProps> = ({ className = '' }) => {
const { slug } = useParams<{ slug: string }>();
const { session } = useAuth();
const [showDropdown, setShowDropdown] = useState(false);
const [boardInfo, setBoardInfo] = useState<BoardInfo | null>(null);
const [editors, setEditors] = useState<Editor[]>([]);
const [isAdmin, setIsAdmin] = useState(false);
const [isGlobalAdmin, setIsGlobalAdmin] = useState(false);
const [loading, setLoading] = useState(false);
const [updating, setUpdating] = useState(false);
const [requestingAdmin, setRequestingAdmin] = useState(false);
const [adminRequestSent, setAdminRequestSent] = useState(false);
const [adminRequestError, setAdminRequestError] = useState<string | null>(null);
const [inviteInput, setInviteInput] = useState('');
const [inviteStatus, setInviteStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const dropdownRef = useRef<HTMLDivElement>(null);
const dropdownMenuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null);
const boardId = slug || 'mycofi33';
// Get auth headers
const getAuthHeaders = (): Record<string, string> => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (session.authed && session.username) {
const publicKey = crypto.getPublicKey(session.username);
if (publicKey) {
headers['X-CryptID-PublicKey'] = publicKey;
}
}
return headers;
};
// Fetch board info and admin status
const fetchBoardData = async () => {
setLoading(true);
try {
const headers = getAuthHeaders();
// Fetch board info
const infoRes = await fetch(`${WORKER_URL}/boards/${boardId}/info`, { headers });
const infoData = await infoRes.json() as { board?: BoardInfo };
if (infoData.board) {
setBoardInfo(infoData.board);
}
// Fetch permission to check if admin
const permRes = await fetch(`${WORKER_URL}/boards/${boardId}/permission`, { headers });
const permData = await permRes.json() as { permission?: string; isGlobalAdmin?: boolean };
setIsAdmin(permData.permission === 'admin');
setIsGlobalAdmin(permData.isGlobalAdmin || false);
// If admin, fetch editors list
if (permData.permission === 'admin') {
const editorsRes = await fetch(`${WORKER_URL}/boards/${boardId}/editors`, { headers });
const editorsData = await editorsRes.json() as { editors?: Editor[] };
setEditors(editorsData.editors || []);
}
} catch (error) {
console.error('Failed to fetch board data:', error);
} finally {
setLoading(false);
}
};
// Toggle board protection
const toggleProtection = async () => {
if (!boardInfo || updating) return;
setUpdating(true);
try {
const headers = getAuthHeaders();
const res = await fetch(`${WORKER_URL}/boards/${boardId}`, {
method: 'PATCH',
headers,
body: JSON.stringify({ isProtected: !boardInfo.isProtected }),
});
if (res.ok) {
setBoardInfo(prev => prev ? { ...prev, isProtected: !prev.isProtected } : null);
}
} catch (error) {
console.error('Failed to toggle protection:', error);
} finally {
setUpdating(false);
}
};
// Request admin access
const requestAdminAccess = async () => {
if (requestingAdmin || adminRequestSent) return;
setRequestingAdmin(true);
setAdminRequestError(null);
try {
const headers = getAuthHeaders();
const res = await fetch(`${WORKER_URL}/admin/request`, {
method: 'POST',
headers,
body: JSON.stringify({ reason: `Requesting admin access for board: ${boardId}` }),
});
const data = await res.json() as { success?: boolean; message?: string; error?: string };
if (res.ok && data.success) {
setAdminRequestSent(true);
} else {
setAdminRequestError(data.error || data.message || 'Failed to send request');
}
} catch (error) {
console.error('Failed to request admin:', error);
setAdminRequestError('Network error - please try again');
} finally {
setRequestingAdmin(false);
}
};
// Invite user as editor
const inviteEditor = async () => {
if (!inviteInput.trim() || inviteStatus === 'sending') return;
setInviteStatus('sending');
try {
const headers = getAuthHeaders();
const res = await fetch(`${WORKER_URL}/boards/${boardId}/permissions`, {
method: 'POST',
headers,
body: JSON.stringify({
usernameOrEmail: inviteInput.trim(),
permission: 'edit',
}),
});
if (res.ok) {
setInviteStatus('sent');
setInviteInput('');
// Refresh editors list
fetchBoardData();
setTimeout(() => setInviteStatus('idle'), 2000);
} else {
setInviteStatus('error');
setTimeout(() => setInviteStatus('idle'), 3000);
}
} catch (error) {
console.error('Failed to invite editor:', error);
setInviteStatus('error');
setTimeout(() => setInviteStatus('idle'), 3000);
}
};
// Remove editor
const removeEditor = async (userId: string) => {
try {
const headers = getAuthHeaders();
await fetch(`${WORKER_URL}/boards/${boardId}/permissions/${userId}`, {
method: 'DELETE',
headers,
});
setEditors(prev => prev.filter(e => e.userId !== userId));
} catch (error) {
console.error('Failed to remove editor:', error);
}
};
// Update dropdown position when it opens
useEffect(() => {
if (showDropdown && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + 8,
right: window.innerWidth - rect.right,
});
fetchBoardData();
}
}, [showDropdown]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
const isInsideTrigger = dropdownRef.current && dropdownRef.current.contains(target);
const isInsideMenu = dropdownMenuRef.current && dropdownMenuRef.current.contains(target);
if (!isInsideTrigger && !isInsideMenu) {
setShowDropdown(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
setShowDropdown(false);
}
};
if (showDropdown) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown, true);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [showDropdown]);
return (
<div ref={dropdownRef} style={{ pointerEvents: 'all' }}>
<button
ref={triggerRef}
onClick={() => setShowDropdown(!showDropdown)}
className={`board-settings-button ${className}`}
title="Board Settings"
style={{
background: showDropdown ? 'var(--color-muted-2)' : 'none',
border: 'none',
padding: '6px',
cursor: 'pointer',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-1)',
opacity: showDropdown ? 1 : 0.7,
transition: 'opacity 0.15s, background 0.15s',
pointerEvents: 'all',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1';
e.currentTarget.style.background = 'var(--color-muted-2)';
}}
onMouseLeave={(e) => {
if (!showDropdown) {
e.currentTarget.style.opacity = '0.7';
e.currentTarget.style.background = 'none';
}
}}
>
{/* Settings gear icon */}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
{/* Dropdown Menu */}
{showDropdown && dropdownPosition && createPortal(
<div
ref={dropdownMenuRef}
style={{
position: 'fixed',
top: dropdownPosition.top,
right: dropdownPosition.right,
width: '320px',
maxHeight: '80vh',
overflowY: 'auto',
background: 'var(--color-panel)',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
zIndex: 100000,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
}}
onWheel={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div style={{
padding: '12px 14px',
borderBottom: '1px solid var(--color-panel-contrast)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px' }}>&#9881;</span> Board Settings
</span>
<button
onClick={() => setShowDropdown(false)}
style={{
background: 'var(--color-muted-2)',
border: 'none',
cursor: 'pointer',
padding: '4px 8px',
color: 'var(--color-text-3)',
fontSize: '11px',
fontFamily: 'inherit',
borderRadius: '4px',
}}
>
&#10005;
</button>
</div>
{loading ? (
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--color-text-3)' }}>
Loading...
</div>
) : (
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* Board Info */}
<div>
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-3)', marginBottom: '8px', textTransform: 'uppercase' }}>
Board Info
</div>
<div style={{ fontSize: '12px', color: 'var(--color-text)' }}>
<div style={{ marginBottom: '4px' }}>
<strong>ID:</strong> {boardId}
</div>
{boardInfo?.ownerUsername && (
<div style={{ marginBottom: '4px' }}>
<strong>Owner:</strong> @{boardInfo.ownerUsername}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<strong>Status:</strong>
<span style={{
padding: '2px 8px',
borderRadius: '4px',
fontSize: '11px',
fontWeight: 500,
background: boardInfo?.isProtected ? '#fef3c7' : '#d1fae5',
color: boardInfo?.isProtected ? '#92400e' : '#065f46',
}}>
{boardInfo?.isProtected ? 'Protected (View-only)' : 'Open (Anyone can edit)'}
</span>
</div>
</div>
</div>
{/* Admin Section */}
{isAdmin && (
<>
<div style={{ borderTop: '1px solid var(--color-panel-contrast)', paddingTop: '12px' }}>
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-3)', marginBottom: '8px', textTransform: 'uppercase' }}>
Protection Settings {isGlobalAdmin && <span style={{ color: '#3b82f6' }}>(Global Admin)</span>}
</div>
{/* Protection Toggle */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
background: 'var(--color-muted-2)',
borderRadius: '8px',
}}>
<div>
<div style={{ fontSize: '12px', fontWeight: 500, color: 'var(--color-text)' }}>
View-only Mode
</div>
<div style={{ fontSize: '10px', color: 'var(--color-text-3)' }}>
Only listed editors can make changes
</div>
</div>
<button
onClick={toggleProtection}
disabled={updating}
style={{
width: '44px',
height: '24px',
borderRadius: '12px',
border: 'none',
cursor: updating ? 'not-allowed' : 'pointer',
background: boardInfo?.isProtected ? '#3b82f6' : '#d1d5db',
position: 'relative',
transition: 'background 0.2s',
}}
>
<div style={{
width: '20px',
height: '20px',
borderRadius: '10px',
background: 'white',
position: 'absolute',
top: '2px',
left: boardInfo?.isProtected ? '22px' : '2px',
transition: 'left 0.2s',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
</div>
</div>
{/* Editor Management (only when protected) */}
{boardInfo?.isProtected && (
<div>
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-3)', marginBottom: '8px', textTransform: 'uppercase' }}>
Editors ({editors.length})
</div>
{/* Add Editor Input */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
<input
type="text"
placeholder="Username or email..."
value={inviteInput}
onChange={(e) => setInviteInput(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter') inviteEditor();
}}
style={{
flex: 1,
padding: '8px 12px',
fontSize: '12px',
fontFamily: 'inherit',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '6px',
background: 'var(--color-panel)',
color: 'var(--color-text)',
outline: 'none',
}}
/>
<button
onClick={inviteEditor}
disabled={!inviteInput.trim() || inviteStatus === 'sending'}
style={{
padding: '8px 14px',
backgroundColor: inviteStatus === 'sent' ? '#10b981' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: !inviteInput.trim() || inviteStatus === 'sending' ? 'not-allowed' : 'pointer',
fontSize: '11px',
fontWeight: 500,
fontFamily: 'inherit',
opacity: !inviteInput.trim() ? 0.5 : 1,
}}
>
{inviteStatus === 'sending' ? '...' : inviteStatus === 'sent' ? 'Added' : 'Add'}
</button>
</div>
{/* Editor List */}
<div style={{ maxHeight: '150px', overflowY: 'auto' }}>
{editors.length === 0 ? (
<div style={{ fontSize: '11px', color: 'var(--color-text-3)', textAlign: 'center', padding: '10px' }}>
No editors added yet
</div>
) : (
editors.map((editor) => (
<div
key={editor.userId}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px',
borderRadius: '6px',
marginBottom: '4px',
background: 'var(--color-muted-2)',
}}
>
<div>
<div style={{ fontSize: '12px', fontWeight: 500, color: 'var(--color-text)' }}>
@{editor.username}
</div>
<div style={{ fontSize: '10px', color: 'var(--color-text-3)' }}>
{editor.permission}
</div>
</div>
<button
onClick={() => removeEditor(editor.userId)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: '#ef4444',
fontSize: '14px',
padding: '4px',
}}
title="Remove editor"
>
&#10005;
</button>
</div>
))
)}
</div>
</div>
)}
</>
)}
{/* Request Admin Access (for non-admins) */}
{!isAdmin && session.authed && (
<div style={{ borderTop: '1px solid var(--color-panel-contrast)', paddingTop: '12px' }}>
<button
onClick={requestAdminAccess}
disabled={requestingAdmin || adminRequestSent}
style={{
width: '100%',
padding: '10px',
backgroundColor: adminRequestSent ? '#10b981' : adminRequestError ? '#ef4444' : 'var(--color-muted-2)',
color: adminRequestSent || adminRequestError ? 'white' : 'var(--color-text)',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '8px',
cursor: requestingAdmin || adminRequestSent ? 'not-allowed' : 'pointer',
fontSize: '12px',
fontWeight: 500,
fontFamily: 'inherit',
}}
>
{requestingAdmin ? 'Sending request...' : adminRequestSent ? 'Request Sent!' : adminRequestError ? 'Retry Request' : 'Request Admin Access'}
</button>
{adminRequestError && (
<div style={{ fontSize: '10px', color: '#ef4444', marginTop: '6px', textAlign: 'center' }}>
{adminRequestError}
</div>
)}
<div style={{ fontSize: '10px', color: 'var(--color-text-3)', marginTop: '6px', textAlign: 'center' }}>
Admin requests are sent to jeffemmett@gmail.com
</div>
</div>
)}
{/* Sign in prompt for anonymous users */}
{!session.authed && (
<div style={{ fontSize: '11px', color: 'var(--color-text-3)', textAlign: 'center', padding: '10px' }}>
Sign in to access board settings
</div>
)}
</div>
)}
</div>,
document.body
)}
</div>
);
};
export default BoardSettingsDropdown;

View File

@ -0,0 +1,670 @@
// Calendar Panel - Month/Week view with event list
// Used inside CalendarBrowserShape
import React, { useState, useMemo, useCallback } from "react"
import {
useCalendarEvents,
type DecryptedCalendarEvent,
} from "@/hooks/useCalendarEvents"
interface CalendarPanelProps {
onClose?: () => void
onEventSelect?: (event: DecryptedCalendarEvent) => void
shapeMode?: boolean
initialView?: "month" | "week"
initialDate?: Date
}
type ViewMode = "month" | "week"
// Helper functions
const getDaysInMonth = (year: number, month: number) => {
return new Date(year, month + 1, 0).getDate()
}
const getFirstDayOfMonth = (year: number, month: number) => {
const day = new Date(year, month, 1).getDay()
// Convert Sunday (0) to 7 for Monday-first week
return day === 0 ? 6 : day - 1
}
const formatMonthYear = (date: Date) => {
return date.toLocaleDateString("en-US", { month: "long", year: "numeric" })
}
const isSameDay = (date1: Date, date2: Date) => {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
)
}
const isToday = (date: Date) => {
return isSameDay(date, new Date())
}
export function CalendarPanel({
onClose: _onClose,
onEventSelect,
shapeMode: _shapeMode = false,
initialView = "month",
initialDate,
}: CalendarPanelProps) {
const [viewMode, setViewMode] = useState<ViewMode>(initialView)
const [currentDate, setCurrentDate] = useState(initialDate || new Date())
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
// Detect dark mode
const isDarkMode =
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark")
// Get calendar events for the visible range
const startOfVisibleRange = useMemo(() => {
const year = currentDate.getFullYear()
const month = currentDate.getMonth()
// Start from previous month to show leading days
return new Date(year, month - 1, 1)
}, [currentDate])
const endOfVisibleRange = useMemo(() => {
const year = currentDate.getFullYear()
const month = currentDate.getMonth()
// End at next month to show trailing days
return new Date(year, month + 2, 0)
}, [currentDate])
const { events, loading, error, refresh, getEventsForDate, getUpcoming } =
useCalendarEvents({
startDate: startOfVisibleRange,
endDate: endOfVisibleRange,
})
// Colors
const colors = isDarkMode
? {
bg: "#1a1a1a",
cardBg: "#252525",
headerBg: "#22c55e",
text: "#e4e4e7",
textMuted: "#a1a1aa",
border: "#404040",
todayBg: "#22c55e20",
selectedBg: "#3b82f620",
eventDot: "#3b82f6",
buttonBg: "#374151",
buttonHover: "#4b5563",
}
: {
bg: "#ffffff",
cardBg: "#f9fafb",
headerBg: "#22c55e",
text: "#1f2937",
textMuted: "#6b7280",
border: "#e5e7eb",
todayBg: "#22c55e15",
selectedBg: "#3b82f615",
eventDot: "#3b82f6",
buttonBg: "#f3f4f6",
buttonHover: "#e5e7eb",
}
// Navigation handlers
const goToPrevious = () => {
if (viewMode === "month") {
setCurrentDate(
new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1)
)
} else {
const newDate = new Date(currentDate)
newDate.setDate(newDate.getDate() - 7)
setCurrentDate(newDate)
}
}
const goToNext = () => {
if (viewMode === "month") {
setCurrentDate(
new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1)
)
} else {
const newDate = new Date(currentDate)
newDate.setDate(newDate.getDate() + 7)
setCurrentDate(newDate)
}
}
const goToToday = () => {
setCurrentDate(new Date())
setSelectedDate(new Date())
}
// Generate month grid
const monthGrid = useMemo(() => {
const year = currentDate.getFullYear()
const month = currentDate.getMonth()
const daysInMonth = getDaysInMonth(year, month)
const firstDay = getFirstDayOfMonth(year, month)
const days: { date: Date; isCurrentMonth: boolean }[] = []
// Previous month days
const prevMonth = month === 0 ? 11 : month - 1
const prevYear = month === 0 ? year - 1 : year
const daysInPrevMonth = getDaysInMonth(prevYear, prevMonth)
for (let i = firstDay - 1; i >= 0; i--) {
days.push({
date: new Date(prevYear, prevMonth, daysInPrevMonth - i),
isCurrentMonth: false,
})
}
// Current month days
for (let i = 1; i <= daysInMonth; i++) {
days.push({
date: new Date(year, month, i),
isCurrentMonth: true,
})
}
// Next month days to complete grid
const nextMonth = month === 11 ? 0 : month + 1
const nextYear = month === 11 ? year + 1 : year
const remainingDays = 42 - days.length // 6 rows * 7 days
for (let i = 1; i <= remainingDays; i++) {
days.push({
date: new Date(nextYear, nextMonth, i),
isCurrentMonth: false,
})
}
return days
}, [currentDate])
// Format event time
const formatEventTime = (event: DecryptedCalendarEvent) => {
if (event.isAllDay) return "All day"
return event.startTime.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})
}
// Upcoming events for sidebar
const upcomingEvents = useMemo(() => {
return getUpcoming(10)
}, [getUpcoming])
// Events for selected date
const selectedDateEvents = useMemo(() => {
if (!selectedDate) return []
return getEventsForDate(selectedDate)
}, [selectedDate, getEventsForDate])
// Day cell component
const DayCell = ({
date,
isCurrentMonth,
}: {
date: Date
isCurrentMonth: boolean
}) => {
const dayEvents = getEventsForDate(date)
const isSelectedDate = selectedDate && isSameDay(date, selectedDate)
const isTodayDate = isToday(date)
return (
<div
onClick={() => setSelectedDate(date)}
style={{
padding: "4px",
minHeight: "60px",
cursor: "pointer",
backgroundColor: isSelectedDate
? colors.selectedBg
: isTodayDate
? colors.todayBg
: "transparent",
borderRadius: "4px",
border: isTodayDate
? `2px solid ${colors.headerBg}`
: "1px solid transparent",
transition: "background-color 0.15s ease",
}}
onMouseEnter={(e) => {
if (!isSelectedDate && !isTodayDate) {
e.currentTarget.style.backgroundColor = colors.buttonBg
}
}}
onMouseLeave={(e) => {
if (!isSelectedDate && !isTodayDate) {
e.currentTarget.style.backgroundColor = "transparent"
}
}}
>
<div
style={{
fontSize: "12px",
fontWeight: isTodayDate ? "700" : "500",
color: isCurrentMonth ? colors.text : colors.textMuted,
marginBottom: "4px",
}}
>
{date.getDate()}
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "2px" }}>
{dayEvents.slice(0, 3).map((event) => (
<div
key={event.id}
style={{
width: "6px",
height: "6px",
borderRadius: "50%",
backgroundColor: colors.eventDot,
}}
title={event.summary}
/>
))}
{dayEvents.length > 3 && (
<div
style={{
fontSize: "9px",
color: colors.textMuted,
}}
>
+{dayEvents.length - 3}
</div>
)}
</div>
</div>
)
}
// Event list item
const EventItem = ({ event }: { event: DecryptedCalendarEvent }) => (
<div
onClick={() => onEventSelect?.(event)}
style={{
padding: "10px 12px",
backgroundColor: colors.cardBg,
borderRadius: "8px",
cursor: "pointer",
borderLeft: `3px solid ${colors.eventDot}`,
transition: "background-color 0.15s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = colors.buttonBg
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = colors.cardBg
}}
>
<div
style={{
fontSize: "13px",
fontWeight: "600",
color: colors.text,
marginBottom: "4px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{event.summary}
</div>
<div
style={{
fontSize: "11px",
color: colors.textMuted,
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span>{formatEventTime(event)}</span>
{event.location && (
<>
<span>|</span>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{event.location}
</span>
</>
)}
</div>
</div>
)
// Loading state
if (loading) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: colors.textMuted,
}}
>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: "24px", marginBottom: "8px" }}>Loading...</div>
<div style={{ fontSize: "12px" }}>Fetching calendar events</div>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: colors.textMuted,
}}
>
<div style={{ textAlign: "center", padding: "20px" }}>
<div style={{ fontSize: "24px", marginBottom: "8px" }}>Error</div>
<div style={{ fontSize: "12px", marginBottom: "16px" }}>{error}</div>
<button
onClick={refresh}
style={{
padding: "8px 16px",
backgroundColor: colors.headerBg,
color: "#fff",
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontSize: "13px",
}}
>
Retry
</button>
</div>
</div>
)
}
// No events state
if (events.length === 0) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: colors.textMuted,
}}
>
<div style={{ textAlign: "center", padding: "20px" }}>
<div style={{ fontSize: "48px", marginBottom: "16px" }}>📅</div>
<div style={{ fontSize: "16px", fontWeight: "600", marginBottom: "8px" }}>
No Calendar Events
</div>
<div style={{ fontSize: "12px", marginBottom: "16px" }}>
Import your Google Calendar to see events here.
</div>
</div>
</div>
)
}
return (
<div
style={{
display: "flex",
height: "100%",
backgroundColor: colors.bg,
color: colors.text,
}}
>
{/* Main Calendar Area */}
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
borderRight: `1px solid ${colors.border}`,
}}
>
{/* Navigation Header */}
<div
style={{
padding: "12px 16px",
display: "flex",
alignItems: "center",
gap: "12px",
borderBottom: `1px solid ${colors.border}`,
}}
>
<button
onClick={goToPrevious}
style={{
padding: "6px 12px",
backgroundColor: colors.buttonBg,
color: colors.text,
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontSize: "14px",
}}
>
&lt;
</button>
<div
style={{
flex: 1,
textAlign: "center",
fontSize: "16px",
fontWeight: "600",
}}
>
{formatMonthYear(currentDate)}
</div>
<button
onClick={goToNext}
style={{
padding: "6px 12px",
backgroundColor: colors.buttonBg,
color: colors.text,
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontSize: "14px",
}}
>
&gt;
</button>
<button
onClick={goToToday}
style={{
padding: "6px 12px",
backgroundColor: colors.headerBg,
color: "#fff",
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontSize: "12px",
fontWeight: "600",
}}
>
Today
</button>
{/* View toggle */}
<div
style={{
display: "flex",
backgroundColor: colors.buttonBg,
borderRadius: "6px",
overflow: "hidden",
}}
>
<button
onClick={() => setViewMode("month")}
style={{
padding: "6px 12px",
backgroundColor:
viewMode === "month" ? colors.headerBg : "transparent",
color: viewMode === "month" ? "#fff" : colors.text,
border: "none",
cursor: "pointer",
fontSize: "12px",
}}
>
Month
</button>
<button
onClick={() => setViewMode("week")}
style={{
padding: "6px 12px",
backgroundColor:
viewMode === "week" ? colors.headerBg : "transparent",
color: viewMode === "week" ? "#fff" : colors.text,
border: "none",
cursor: "pointer",
fontSize: "12px",
}}
>
Week
</button>
</div>
</div>
{/* Calendar Grid */}
<div style={{ flex: 1, padding: "12px", overflow: "auto" }}>
{/* Day headers */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "4px",
marginBottom: "8px",
}}
>
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((day) => (
<div
key={day}
style={{
textAlign: "center",
fontSize: "11px",
fontWeight: "600",
color: colors.textMuted,
padding: "4px",
}}
>
{day}
</div>
))}
</div>
{/* Day cells */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "4px",
}}
>
{monthGrid.map(({ date, isCurrentMonth }, i) => (
<DayCell key={i} date={date} isCurrentMonth={isCurrentMonth} />
))}
</div>
</div>
</div>
{/* Sidebar - Events */}
<div
style={{
width: "280px",
display: "flex",
flexDirection: "column",
}}
>
{/* Selected Date Events or Upcoming */}
<div
style={{
flex: 1,
padding: "12px",
overflow: "auto",
}}
>
<div
style={{
fontSize: "12px",
fontWeight: "600",
color: colors.textMuted,
marginBottom: "12px",
textTransform: "uppercase",
letterSpacing: "0.5px",
}}
>
{selectedDate
? selectedDate.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
})
: "Upcoming Events"}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{(selectedDate ? selectedDateEvents : upcomingEvents).map(
(event) => (
<EventItem key={event.id} event={event} />
)
)}
{(selectedDate ? selectedDateEvents : upcomingEvents).length ===
0 && (
<div
style={{
textAlign: "center",
padding: "20px",
color: colors.textMuted,
fontSize: "12px",
}}
>
{selectedDate
? "No events on this day"
: "No upcoming events"}
</div>
)}
</div>
</div>
{/* Click hint */}
{onEventSelect && (
<div
style={{
padding: "12px",
borderTop: `1px solid ${colors.border}`,
fontSize: "11px",
color: colors.textMuted,
textAlign: "center",
}}
>
Click an event to add it to the canvas
</div>
)}
</div>
</div>
)
}
export default CalendarPanel

View File

@ -0,0 +1,262 @@
import { useState, useEffect } from 'react'
import { ConnectionState } from '../automerge/CloudflareAdapter'
interface ConnectionStatusIndicatorProps {
connectionState: ConnectionState
isNetworkOnline: boolean
}
export function ConnectionStatusIndicator({
connectionState,
isNetworkOnline
}: ConnectionStatusIndicatorProps) {
const [showDetails, setShowDetails] = useState(false)
const [isVisible, setIsVisible] = useState(false)
// Determine if we're truly offline (no network OR disconnected for a while)
const isOffline = !isNetworkOnline || connectionState === 'disconnected'
const isReconnecting = connectionState === 'reconnecting' || connectionState === 'connecting'
// Don't show anything when connected and online
useEffect(() => {
if (connectionState === 'connected' && isNetworkOnline) {
// Fade out
setIsVisible(false)
setShowDetails(false)
} else {
// Fade in
setIsVisible(true)
}
}, [connectionState, isNetworkOnline])
if (!isVisible && connectionState === 'connected' && isNetworkOnline) {
return null
}
const getStatusInfo = () => {
if (!isNetworkOnline) {
return {
label: 'Working Offline',
color: '#8b5cf6', // Purple - calm, not alarming
icon: '🍄',
pulse: false,
description: 'Viewing locally saved canvas',
detailedMessage: `You're viewing your locally cached canvas. All your previous work is safely stored in your browser. Any changes you make will be saved locally and automatically synced when you reconnect — no data will be lost.`,
}
}
switch (connectionState) {
case 'connecting':
return {
label: 'Connecting',
color: '#f59e0b', // amber
icon: '🌱',
pulse: true,
description: 'Establishing secure connection...',
detailedMessage: 'Connecting to the collaborative canvas. Your local changes are safely stored.',
}
case 'reconnecting':
return {
label: 'Reconnecting',
color: '#f59e0b', // amber
icon: '🔄',
pulse: true,
description: 'Re-establishing connection...',
detailedMessage: 'Connection interrupted. Attempting to reconnect. All your changes are saved locally and will sync automatically once the connection is restored.',
}
case 'disconnected':
return {
label: 'Disconnected',
color: '#8b5cf6', // Purple
icon: '🍄',
pulse: false,
description: 'Working in local mode',
detailedMessage: `Your canvas is stored securely in your browser using encrypted local storage. All changes are preserved with your personal encryption key. When connectivity is restored, your work will automatically merge with the shared canvas.`,
}
default:
return null
}
}
const status = getStatusInfo()
if (!status) return null
return (
<>
<div
onClick={() => setShowDetails(!showDetails)}
style={{
position: 'fixed',
bottom: '16px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 9999,
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: showDetails ? '12px 16px' : '10px 16px',
backgroundColor: 'rgba(30, 30, 30, 0.95)',
color: 'white',
borderRadius: showDetails ? '16px' : '24px',
fontSize: '14px',
fontFamily: "'Inter', system-ui, -apple-system, sans-serif",
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255,255,255,0.1)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
cursor: 'pointer',
transition: 'all 0.3s ease',
maxWidth: showDetails ? '380px' : '320px',
opacity: isVisible ? 1 : 0,
animation: status.pulse ? 'gentlePulse 3s infinite' : undefined,
}}
>
{/* Status indicator dot */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
flexShrink: 0,
}}>
<span style={{ fontSize: '18px' }}>{status.icon}</span>
<span
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: status.color,
boxShadow: `0 0 8px ${status.color}`,
animation: status.pulse ? 'blink 1.5s infinite' : undefined,
}}
/>
</div>
{/* Main content */}
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
flex: 1,
minWidth: 0,
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<span style={{
fontWeight: 600,
color: status.color,
letterSpacing: '-0.01em',
}}>
{status.label}
</span>
<span style={{
opacity: 0.7,
fontSize: '12px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{status.description}
</span>
</div>
{/* Detailed message when expanded */}
{showDetails && (
<div style={{
fontSize: '12px',
lineHeight: '1.5',
opacity: 0.85,
marginTop: '6px',
paddingTop: '8px',
borderTop: '1px solid rgba(255,255,255,0.1)',
}}>
{status.detailedMessage}
{/* Data sovereignty badges */}
<div style={{
display: 'flex',
gap: '8px',
marginTop: '10px',
flexWrap: 'wrap',
}}>
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
backgroundColor: 'rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
fontSize: '10px',
fontWeight: 500,
color: '#a78bfa',
}}>
🔐 Encrypted Locally
</span>
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
backgroundColor: 'rgba(16, 185, 129, 0.2)',
borderRadius: '8px',
fontSize: '10px',
fontWeight: 500,
color: '#6ee7b7',
}}>
💾 Auto-Saved
</span>
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
backgroundColor: 'rgba(59, 130, 246, 0.2)',
borderRadius: '8px',
fontSize: '10px',
fontWeight: 500,
color: '#93c5fd',
}}>
🔄 Will Auto-Sync
</span>
</div>
</div>
)}
</div>
{/* Expand indicator */}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
style={{
opacity: 0.5,
transform: showDetails ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease',
flexShrink: 0,
}}
>
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/>
</svg>
</div>
<style>{`
@keyframes gentlePulse {
0%, 100% {
opacity: 1;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255,255,255,0.1);
}
50% {
opacity: 0.9;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255,255,255,0.15);
}
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
`}</style>
</>
)
}

View File

@ -80,7 +80,6 @@ export function FathomMeetingsPanel({ onClose, onMeetingSelect, shapeMode = fals
}
})
} catch (error) {
console.log('Production worker failed, trying local worker...')
response = await fetch(`${LOCAL_WORKER_URL}/fathom/meetings`, {
headers: {
'X-Api-Key': key,
@ -150,13 +149,6 @@ export function FathomMeetingsPanel({ onClose, onMeetingSelect, shapeMode = fals
// Handler for individual data type buttons - creates shapes directly
const handleDataButtonClick = async (meeting: FathomMeeting, dataType: 'summary' | 'transcript' | 'actionItems' | 'video') => {
// Log to verify the correct meeting is being used
console.log('🔵 handleDataButtonClick called with meeting:', {
recording_id: meeting.recording_id,
title: meeting.title,
dataType
})
if (!onMeetingSelect) {
// Fallback for non-browser mode
const options = {
@ -251,7 +243,6 @@ export function FathomMeetingsPanel({ onClose, onMeetingSelect, shapeMode = fals
(callId ? `https://fathom.video/calls/${callId}` : null)
if (videoUrl) {
console.log('Opening Fathom video URL:', videoUrl, 'for meeting:', { callId, recording_id: meeting.recording_id })
window.open(videoUrl, '_blank', 'noopener,noreferrer')
} else {
console.error('Could not determine Fathom video URL for meeting:', meeting)
@ -272,7 +263,6 @@ export function FathomMeetingsPanel({ onClose, onMeetingSelect, shapeMode = fals
}
})
} catch (error) {
console.log('Production worker failed, trying local worker...')
response = await fetch(`${LOCAL_WORKER_URL}/fathom/meetings/${meeting.recording_id}${includeTranscript ? '?include_transcript=true' : ''}`, {
headers: {
'X-Api-Key': apiKey,

View File

@ -0,0 +1,467 @@
// Simple test component for Google Data Sovereignty OAuth flow
import { useState, useEffect } from 'react';
import {
initiateGoogleAuth,
handleGoogleCallback,
parseCallbackParams,
isGoogleAuthenticated,
getGrantedScopes,
generateMasterKey,
importGmail,
importDrive,
importPhotos,
importCalendar,
gmailStore,
driveStore,
photosStore,
calendarStore,
deleteDatabase,
createShareService,
type GoogleService,
type ImportProgress,
type ShareableItem
} from '../lib/google';
export function GoogleDataTest() {
const [status, setStatus] = useState<string>('Initializing...');
const [isAuthed, setIsAuthed] = useState(false);
const [scopes, setScopes] = useState<string[]>([]);
const [masterKey, setMasterKey] = useState<CryptoKey | null>(null);
const [error, setError] = useState<string | null>(null);
const [importProgress, setImportProgress] = useState<ImportProgress | null>(null);
const [storedCounts, setStoredCounts] = useState<{gmail: number; drive: number; photos: number; calendar: number}>({
gmail: 0, drive: 0, photos: 0, calendar: 0
});
const [logs, setLogs] = useState<string[]>([]);
const [viewingService, setViewingService] = useState<GoogleService | null>(null);
const [viewItems, setViewItems] = useState<ShareableItem[]>([]);
const addLog = (msg: string) => {
setLogs(prev => [...prev.slice(-20), `${new Date().toLocaleTimeString()}: ${msg}`]);
};
// Initialize on mount
useEffect(() => {
initializeService();
}, []);
// Check for OAuth callback - wait for masterKey to be ready
useEffect(() => {
const url = window.location.href;
if (url.includes('/oauth/google/callback') && masterKey) {
handleCallback(url);
}
}, [masterKey]); // Re-run when masterKey becomes available
async function initializeService() {
try {
// Generate or load master key
const key = await generateMasterKey();
setMasterKey(key);
// Check if already authenticated
const authed = await isGoogleAuthenticated();
setIsAuthed(authed);
if (authed) {
const grantedScopes = await getGrantedScopes();
setScopes(grantedScopes);
setStatus('Authenticated with Google');
} else {
setStatus('Ready to connect to Google');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Initialization failed');
setStatus('Error');
}
}
async function handleCallback(url: string) {
setStatus('Processing OAuth callback...');
const params = parseCallbackParams(url);
if (params.error) {
setError(`OAuth error: ${params.error_description || params.error}`);
setStatus('Error');
return;
}
if (params.code && params.state && masterKey) {
const result = await handleGoogleCallback(params.code, params.state, masterKey);
if (result.success) {
setIsAuthed(true);
setScopes(result.scopes);
setStatus('Successfully connected to Google!');
// Clean up URL
window.history.replaceState({}, '', '/');
} else {
setError(result.error || 'Callback failed');
setStatus('Error');
}
}
}
async function connectGoogle() {
setStatus('Redirecting to Google...');
const services: GoogleService[] = ['gmail', 'drive', 'photos', 'calendar'];
await initiateGoogleAuth(services);
}
async function resetAndReconnect() {
addLog('Resetting: Clearing all data...');
try {
await deleteDatabase();
addLog('Resetting: Database cleared');
setIsAuthed(false);
setScopes([]);
setStoredCounts({ gmail: 0, drive: 0, photos: 0, calendar: 0 });
setError(null);
setStatus('Database cleared. Click Connect to re-authenticate.');
addLog('Resetting: Done. Please re-connect to Google.');
} catch (err) {
addLog(`Resetting: ERROR - ${err}`);
}
}
async function viewData(service: GoogleService) {
if (!masterKey) return;
addLog(`Viewing ${service} data...`);
try {
const shareService = createShareService(masterKey);
const items = await shareService.listShareableItems(service, 20);
addLog(`Found ${items.length} ${service} items`);
setViewItems(items);
setViewingService(service);
} catch (err) {
addLog(`View error: ${err}`);
setError(err instanceof Error ? err.message : String(err));
}
}
async function refreshCounts() {
const [gmail, drive, photos, calendar] = await Promise.all([
gmailStore.count(),
driveStore.count(),
photosStore.count(),
calendarStore.count()
]);
setStoredCounts({ gmail, drive, photos, calendar });
}
async function testImportGmail() {
addLog('Gmail: Starting...');
if (!masterKey) {
addLog('Gmail: ERROR - No master key');
setError('No master key available');
return;
}
setError(null);
setImportProgress(null);
setStatus('Importing Gmail (max 10 messages)...');
try {
addLog('Gmail: Calling importGmail...');
const result = await importGmail(masterKey, {
maxMessages: 10,
onProgress: (p) => {
addLog(`Gmail: Progress ${p.imported}/${p.total} - ${p.status}`);
setImportProgress(p);
}
});
addLog(`Gmail: Result - ${result.status}, ${result.imported} items`);
setImportProgress(result);
if (result.status === 'error') {
addLog(`Gmail: ERROR - ${result.errorMessage}`);
setError(result.errorMessage || 'Unknown error');
setStatus('Gmail import failed');
} else {
setStatus(`Gmail import ${result.status}: ${result.imported} messages`);
}
await refreshCounts();
} catch (err) {
const errorMsg = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
addLog(`Gmail: EXCEPTION - ${errorMsg}`);
setError(errorMsg);
setStatus('Gmail import error');
}
}
async function testImportDrive() {
if (!masterKey) return;
setError(null);
setStatus('Importing Drive (max 10 files)...');
try {
const result = await importDrive(masterKey, {
maxFiles: 10,
onProgress: (p) => setImportProgress(p)
});
setImportProgress(result);
setStatus(`Drive import ${result.status}: ${result.imported} files`);
await refreshCounts();
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed');
setStatus('Error');
}
}
async function testImportPhotos() {
if (!masterKey) return;
setError(null);
setStatus('Importing Photos (max 10 thumbnails)...');
try {
const result = await importPhotos(masterKey, {
maxPhotos: 10,
onProgress: (p) => setImportProgress(p)
});
setImportProgress(result);
setStatus(`Photos import ${result.status}: ${result.imported} photos`);
await refreshCounts();
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed');
setStatus('Error');
}
}
async function testImportCalendar() {
if (!masterKey) return;
setError(null);
setStatus('Importing Calendar (max 20 events)...');
try {
const result = await importCalendar(masterKey, {
maxEvents: 20,
onProgress: (p) => setImportProgress(p)
});
setImportProgress(result);
setStatus(`Calendar import ${result.status}: ${result.imported} events`);
await refreshCounts();
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed');
setStatus('Error');
}
}
const buttonStyle = {
padding: '10px 16px',
fontSize: '14px',
background: '#1a73e8',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
marginRight: '10px',
marginBottom: '10px'
};
return (
<div style={{
padding: '20px',
fontFamily: 'system-ui, sans-serif',
maxWidth: '600px',
margin: '40px auto'
}}>
<h1>Google Data Sovereignty Test</h1>
<div style={{
padding: '15px',
background: error ? '#fee' : '#f0f0f0',
borderRadius: '8px',
marginBottom: '20px'
}}>
<strong>Status:</strong> {status}
{error && (
<div style={{
color: 'red',
marginTop: '10px',
padding: '10px',
background: '#fdd',
borderRadius: '4px',
fontFamily: 'monospace',
fontSize: '12px',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all'
}}>
<strong>Error:</strong> {error}
</div>
)}
</div>
{!isAuthed ? (
<button
onClick={connectGoogle}
style={{
padding: '12px 24px',
fontSize: '16px',
background: '#4285f4',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Connect Google Account
</button>
) : (
<div>
<h3 style={{ color: 'green' }}>Connected!</h3>
<p><strong>Granted scopes:</strong></p>
<ul>
{scopes.map(scope => (
<li key={scope} style={{ fontSize: '12px', fontFamily: 'monospace' }}>
{scope.replace('https://www.googleapis.com/auth/', '')}
</li>
))}
</ul>
<h3>Test Import (Small Batches)</h3>
<div style={{ marginBottom: '20px' }}>
<button style={buttonStyle} onClick={testImportGmail}>
Import Gmail (10)
</button>
<button style={buttonStyle} onClick={testImportDrive}>
Import Drive (10)
</button>
<button style={buttonStyle} onClick={testImportPhotos}>
Import Photos (10)
</button>
<button style={buttonStyle} onClick={testImportCalendar}>
Import Calendar (20)
</button>
</div>
{importProgress && (
<div style={{
padding: '10px',
background: importProgress.status === 'error' ? '#fee' :
importProgress.status === 'completed' ? '#efe' : '#fff3e0',
borderRadius: '4px',
marginBottom: '15px'
}}>
<strong>{importProgress.service}:</strong> {importProgress.status}
{importProgress.status === 'importing' && (
<span> - {importProgress.imported}/{importProgress.total}</span>
)}
{importProgress.status === 'completed' && (
<span> - {importProgress.imported} items imported</span>
)}
{importProgress.errorMessage && (
<div style={{ color: 'red', marginTop: '5px' }}>{importProgress.errorMessage}</div>
)}
</div>
)}
<h3>Stored Data (Encrypted in IndexedDB)</h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
<tr>
<td style={{ padding: '8px', borderBottom: '1px solid #ddd' }}>Gmail</td>
<td style={{ padding: '8px', borderBottom: '1px solid #ddd', textAlign: 'right' }}>{storedCounts.gmail} messages</td>
<td style={{ padding: '8px', borderBottom: '1px solid #ddd', textAlign: 'right' }}>
{storedCounts.gmail > 0 && <button onClick={() => viewData('gmail')} style={{ fontSize: '12px', padding: '4px 8px' }}>View</button>}
</td>
</tr>
<tr>
<td style={{ padding: '8px', borderBottom: '1px solid #ddd' }}>Drive</td>
<td style={{ padding: '8px', borderBottom: '1px solid #ddd', textAlign: 'right' }}>{storedCounts.drive} files</td>
<td style={{ padding: '8px', borderBottom: '1px solid #ddd', textAlign: 'right' }}>
{storedCounts.drive > 0 && <button onClick={() => viewData('drive')} style={{ fontSize: '12px', padding: '4px 8px' }}>View</button>}
</td>
</tr>
<tr>
<td style={{ padding: '8px', borderBottom: '1px solid #ddd' }}>Photos</td>
<td style={{ padding: '8px', borderBottom: '1px solid #ddd', textAlign: 'right' }}>{storedCounts.photos} photos</td>
<td style={{ padding: '8px', borderBottom: '1px solid #ddd', textAlign: 'right' }}>
{storedCounts.photos > 0 && <button onClick={() => viewData('photos')} style={{ fontSize: '12px', padding: '4px 8px' }}>View</button>}
</td>
</tr>
<tr>
<td style={{ padding: '8px', borderBottom: '1px solid #ddd' }}>Calendar</td>
<td style={{ padding: '8px', borderBottom: '1px solid #ddd', textAlign: 'right' }}>{storedCounts.calendar} events</td>
<td style={{ padding: '8px', borderBottom: '1px solid #ddd', textAlign: 'right' }}>
{storedCounts.calendar > 0 && <button onClick={() => viewData('calendar')} style={{ fontSize: '12px', padding: '4px 8px' }}>View</button>}
</td>
</tr>
</tbody>
</table>
{viewingService && viewItems.length > 0 && (
<div style={{ marginTop: '20px' }}>
<h4>
{viewingService.charAt(0).toUpperCase() + viewingService.slice(1)} Items (Decrypted)
<button onClick={() => { setViewingService(null); setViewItems([]); }} style={{ marginLeft: '10px', fontSize: '12px' }}>Close</button>
</h4>
<div style={{ maxHeight: '300px', overflow: 'auto', border: '1px solid #ddd', borderRadius: '4px' }}>
{viewItems.map((item, i) => (
<div key={item.id} style={{
padding: '10px',
borderBottom: '1px solid #eee',
background: i % 2 === 0 ? '#fff' : '#f9f9f9'
}}>
<strong>{item.title}</strong>
<div style={{ fontSize: '12px', color: '#666' }}>
{new Date(item.date).toLocaleString()}
</div>
{item.preview && (
<div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{item.preview.substring(0, 100)}...
</div>
)}
</div>
))}
</div>
</div>
)}
<button
onClick={refreshCounts}
style={{ ...buttonStyle, background: '#666', marginTop: '10px' }}
>
Refresh Counts
</button>
<button
onClick={resetAndReconnect}
style={{ ...buttonStyle, background: '#c00', marginTop: '10px' }}
>
Reset & Clear All Data
</button>
</div>
)}
<hr style={{ margin: '30px 0' }} />
<h3>Activity Log</h3>
<div style={{
background: '#1a1a1a',
color: '#0f0',
padding: '10px',
borderRadius: '4px',
fontFamily: 'monospace',
fontSize: '11px',
height: '150px',
overflow: 'auto',
marginBottom: '20px'
}}>
{logs.length === 0 ? (
<span style={{ color: '#666' }}>Click an import button to see activity...</span>
) : (
logs.map((log, i) => <div key={i}>{log}</div>)
)}
</div>
<details>
<summary style={{ cursor: 'pointer' }}>Debug Info</summary>
<pre style={{ fontSize: '11px', background: '#f5f5f5', padding: '10px', overflow: 'auto' }}>
{JSON.stringify({
isAuthed,
hasMasterKey: !!masterKey,
scopeCount: scopes.length,
storedCounts,
importProgress,
currentUrl: typeof window !== 'undefined' ? window.location.href : 'N/A'
}, null, 2)}
</pre>
</details>
</div>
);
}
export default GoogleDataTest;

View File

@ -0,0 +1,584 @@
import { useState, useEffect, useRef } from 'react';
import { GoogleDataService, type GoogleService, type ShareableItem } from '../lib/google';
interface GoogleExportBrowserProps {
isOpen: boolean;
onClose: () => void;
onAddToCanvas: (items: ShareableItem[], position: { x: number; y: number }) => void;
isDarkMode: boolean;
}
const SERVICE_ICONS: Record<GoogleService, string> = {
gmail: '📧',
drive: '📁',
photos: '📷',
calendar: '📅',
};
const SERVICE_NAMES: Record<GoogleService, string> = {
gmail: 'Gmail',
drive: 'Drive',
photos: 'Photos',
calendar: 'Calendar',
};
export function GoogleExportBrowser({
isOpen,
onClose,
onAddToCanvas,
isDarkMode,
}: GoogleExportBrowserProps) {
const modalRef = useRef<HTMLDivElement>(null);
const [activeTab, setActiveTab] = useState<GoogleService>('gmail');
const [items, setItems] = useState<ShareableItem[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [serviceCounts, setServiceCounts] = useState<Record<GoogleService, number>>({
gmail: 0,
drive: 0,
photos: 0,
calendar: 0,
});
// Dark mode aware colors
const colors = isDarkMode ? {
bg: '#1a1a1a',
cardBg: '#252525',
cardBorder: '#404040',
text: '#e4e4e4',
textMuted: '#a1a1aa',
textHeading: '#f4f4f5',
hoverBg: '#333333',
selectedBg: 'rgba(99, 102, 241, 0.2)',
selectedBorder: 'rgba(99, 102, 241, 0.5)',
tabActiveBg: '#3b82f6',
tabActiveText: '#ffffff',
tabInactiveBg: '#333333',
tabInactiveText: '#a1a1aa',
inputBg: '#333333',
inputBorder: '#404040',
btnPrimaryBg: '#6366f1',
btnPrimaryText: '#ffffff',
btnSecondaryBg: '#333333',
btnSecondaryText: '#e4e4e4',
} : {
bg: '#ffffff',
cardBg: '#f9fafb',
cardBorder: '#e5e7eb',
text: '#374151',
textMuted: '#6b7280',
textHeading: '#1f2937',
hoverBg: '#f3f4f6',
selectedBg: 'rgba(99, 102, 241, 0.1)',
selectedBorder: 'rgba(99, 102, 241, 0.4)',
tabActiveBg: '#3b82f6',
tabActiveText: '#ffffff',
tabInactiveBg: '#f3f4f6',
tabInactiveText: '#6b7280',
inputBg: '#ffffff',
inputBorder: '#e5e7eb',
btnPrimaryBg: '#6366f1',
btnPrimaryText: '#ffffff',
btnSecondaryBg: '#f3f4f6',
btnSecondaryText: '#374151',
};
// Load items when tab changes
useEffect(() => {
if (!isOpen) return;
const loadItems = async () => {
setLoading(true);
setItems([]);
setSelectedIds(new Set());
try {
const service = GoogleDataService.getInstance();
const shareService = service.getShareService();
if (shareService) {
const shareableItems = await shareService.listShareableItems(activeTab, 100);
setItems(shareableItems);
}
// Also update counts
const counts = await service.getStoredCounts();
setServiceCounts(counts);
} catch (error) {
console.error('Failed to load items:', error);
} finally {
setLoading(false);
}
};
loadItems();
}, [isOpen, activeTab]);
// Handle escape key and click outside
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
// Toggle item selection
const toggleSelection = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
// Select/deselect all
const selectAll = () => {
if (selectedIds.size === filteredItems.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filteredItems.map((i) => i.id)));
}
};
// Filter items by search query
const filteredItems = items.filter((item) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return (
item.title.toLowerCase().includes(query) ||
(item.preview && item.preview.toLowerCase().includes(query))
);
});
// Handle add to canvas
const handleAddToCanvas = () => {
const selectedItems = items.filter((i) => selectedIds.has(i.id));
if (selectedItems.length === 0) return;
// Calculate center of viewport for placement
const position = { x: 200, y: 200 }; // Default position, will be adjusted by caller
onAddToCanvas(selectedItems, position);
onClose();
};
// Format date
const formatDate = (timestamp: number) => {
const date = new Date(timestamp);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return date.toLocaleDateString();
};
if (!isOpen) return null;
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100002,
}}
>
<div
ref={modalRef}
style={{
backgroundColor: colors.bg,
borderRadius: '12px',
width: '90%',
maxWidth: '600px',
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: `1px solid ${colors.cardBorder}`,
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: `1px solid ${colors.cardBorder}`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '20px' }}>🔐</span>
<h2 style={{ fontSize: '16px', fontWeight: '600', color: colors.textHeading, margin: 0 }}>
Your Private Data
</h2>
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
fontSize: '20px',
cursor: 'pointer',
color: colors.textMuted,
padding: '4px',
}}
>
×
</button>
</div>
{/* Service tabs */}
<div
style={{
display: 'flex',
gap: '8px',
padding: '12px 20px',
borderBottom: `1px solid ${colors.cardBorder}`,
}}
>
{(['gmail', 'drive', 'photos', 'calendar'] as GoogleService[]).map((service) => (
<button
key={service}
onClick={() => setActiveTab(service)}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
fontSize: '13px',
fontWeight: '500',
backgroundColor: activeTab === service ? colors.tabActiveBg : colors.tabInactiveBg,
color: activeTab === service ? colors.tabActiveText : colors.tabInactiveText,
transition: 'all 0.15s ease',
}}
>
<span>{SERVICE_ICONS[service]}</span>
<span>{SERVICE_NAMES[service]}</span>
{serviceCounts[service] > 0 && (
<span
style={{
fontSize: '11px',
padding: '2px 6px',
borderRadius: '10px',
backgroundColor: activeTab === service ? 'rgba(255,255,255,0.2)' : colors.cardBorder,
}}
>
{serviceCounts[service]}
</span>
)}
</button>
))}
</div>
{/* Search and actions */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 20px',
borderBottom: `1px solid ${colors.cardBorder}`,
}}
>
<div style={{ flex: 1, position: 'relative' }}>
<span
style={{
position: 'absolute',
left: '12px',
top: '50%',
transform: 'translateY(-50%)',
fontSize: '14px',
}}
>
🔍
</span>
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '8px 12px 8px 36px',
borderRadius: '8px',
border: `1px solid ${colors.inputBorder}`,
backgroundColor: colors.inputBg,
color: colors.text,
fontSize: '13px',
outline: 'none',
}}
/>
</div>
<button
onClick={selectAll}
style={{
padding: '8px 12px',
borderRadius: '8px',
border: `1px solid ${colors.cardBorder}`,
backgroundColor: colors.btnSecondaryBg,
color: colors.btnSecondaryText,
fontSize: '12px',
cursor: 'pointer',
}}
>
{selectedIds.size === filteredItems.length && filteredItems.length > 0
? 'Clear'
: 'Select All'}
</button>
</div>
{/* Items list */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '8px 12px',
}}
>
{loading ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
color: colors.textMuted,
}}
>
Loading...
</div>
) : filteredItems.length === 0 ? (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
color: colors.textMuted,
}}
>
<span style={{ fontSize: '32px', marginBottom: '12px' }}>{SERVICE_ICONS[activeTab]}</span>
<p style={{ fontSize: '14px' }}>No {SERVICE_NAMES[activeTab]} data imported yet</p>
<a
href="/google"
style={{ fontSize: '13px', color: '#3b82f6', marginTop: '8px' }}
>
Import data
</a>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{filteredItems.map((item) => (
<div
key={item.id}
onClick={() => toggleSelection(item.id)}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 12px',
borderRadius: '8px',
cursor: 'pointer',
backgroundColor: selectedIds.has(item.id) ? colors.selectedBg : 'transparent',
border: selectedIds.has(item.id)
? `1px solid ${colors.selectedBorder}`
: '1px solid transparent',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
if (!selectedIds.has(item.id)) {
e.currentTarget.style.backgroundColor = colors.hoverBg;
}
}}
onMouseLeave={(e) => {
if (!selectedIds.has(item.id)) {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
{/* Checkbox */}
<div
style={{
width: '18px',
height: '18px',
borderRadius: '4px',
border: `2px solid ${selectedIds.has(item.id) ? '#6366f1' : colors.cardBorder}`,
backgroundColor: selectedIds.has(item.id) ? '#6366f1' : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{selectedIds.has(item.id) && (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="white"
strokeWidth="2"
>
<path d="M2 6l3 3 5-6" />
</svg>
)}
</div>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '13px',
fontWeight: '500',
color: colors.text,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{item.title}
</div>
{item.preview && (
<div
style={{
fontSize: '12px',
color: colors.textMuted,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
marginTop: '2px',
}}
>
{item.preview}
</div>
)}
</div>
{/* Date */}
<div
style={{
fontSize: '11px',
color: colors.textMuted,
flexShrink: 0,
}}
>
{formatDate(item.date)}
</div>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderTop: `1px solid ${colors.cardBorder}`,
}}
>
<div style={{ fontSize: '13px', color: colors.textMuted }}>
{selectedIds.size > 0 ? `${selectedIds.size} item${selectedIds.size > 1 ? 's' : ''} selected` : 'Select items to add'}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={onClose}
style={{
padding: '10px 16px',
borderRadius: '8px',
border: `1px solid ${colors.cardBorder}`,
backgroundColor: colors.btnSecondaryBg,
color: colors.btnSecondaryText,
fontSize: '13px',
fontWeight: '500',
cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={handleAddToCanvas}
disabled={selectedIds.size === 0}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '10px 16px',
borderRadius: '8px',
border: 'none',
backgroundColor: selectedIds.size > 0 ? colors.btnPrimaryBg : colors.btnSecondaryBg,
color: selectedIds.size > 0 ? colors.btnPrimaryText : colors.textMuted,
fontSize: '13px',
fontWeight: '500',
cursor: selectedIds.size > 0 ? 'pointer' : 'not-allowed',
opacity: selectedIds.size > 0 ? 1 : 0.6,
}}
>
<span>🔒</span>
Add to Private Workspace
</button>
</div>
</div>
{/* Privacy note */}
<div
style={{
padding: '12px 20px',
backgroundColor: colors.cardBg,
borderTop: `1px solid ${colors.cardBorder}`,
borderRadius: '0 0 12px 12px',
}}
>
<p
style={{
fontSize: '11px',
color: colors.textMuted,
textAlign: 'center',
margin: 0,
}}
>
🔒 Private = Only you can see (encrypted in browser) Drag outside Private Workspace to share
</p>
</div>
</div>
</div>
);
}

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'
import { holosphereService, HoloSphereService, HolonData, HolonLens } from '@/lib/HoloSphereService'
import { holosphereService, HoloSphereService, HolonData, HolonLens, HOLON_ENABLED } from '@/lib/HoloSphereService'
import * as h3 from 'h3-js'
interface HolonBrowserProps {
@ -32,6 +32,66 @@ export function HolonBrowser({ isOpen, onClose, onSelectHolon, shapeMode = false
const [isLoadingData, setIsLoadingData] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
// If Holon functionality is disabled, show a disabled message
if (!HOLON_ENABLED) {
if (!isOpen) return null
const disabledContent = (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
height: '100%',
textAlign: 'center'
}}>
<div style={{ fontSize: '64px', marginBottom: '24px' }}>🌐</div>
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#374151', marginBottom: '12px' }}>
Holon Browser Disabled
</h2>
<p style={{ fontSize: '14px', color: '#6b7280', maxWidth: '400px' }}>
Holon functionality is currently disabled while awaiting Nostr integration.
This feature will be re-enabled in a future update.
</p>
{!shapeMode && (
<button
onClick={onClose}
style={{
marginTop: '24px',
padding: '8px 16px',
backgroundColor: '#6b7280',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer'
}}
>
Close
</button>
)}
</div>
)
if (shapeMode) {
return disabledContent
}
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
onClick={onClose}
>
<div
className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden z-[10000]"
onClick={(e) => e.stopPropagation()}
>
{disabledContent}
</div>
</div>
)
}
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus()
@ -82,7 +142,6 @@ export function HolonBrowser({ isOpen, onClose, onSelectHolon, shapeMode = false
try {
metadata = await holosphereService.getData(holonId, 'metadata')
} catch (error) {
console.log('No metadata found for holon')
}
// Get available lenses by trying to fetch data from common lens types
@ -101,7 +160,6 @@ export function HolonBrowser({ isOpen, onClose, onSelectHolon, shapeMode = false
const data = await holosphereService.getDataWithWait(holonId, lens, 1000)
if (data && (Array.isArray(data) ? data.length > 0 : Object.keys(data).length > 0)) {
availableLenses.push(lens)
console.log(`✓ Found lens: ${lens} with ${Object.keys(data).length} keys`)
}
} catch (error) {
// Lens doesn't exist or is empty, skip
@ -147,7 +205,6 @@ export function HolonBrowser({ isOpen, onClose, onSelectHolon, shapeMode = false
// Use getDataWithWait for better Gun data retrieval
const data = await holosphereService.getDataWithWait(holonInfo.id, lens, 2000)
setLensData(data)
console.log(`📊 Loaded lens data for ${lens}:`, data)
} catch (error) {
console.error('Error loading lens data:', error)
setLensData(null)

View File

@ -0,0 +1,477 @@
/**
* Miro Import Dialog
*
* A dialog component for importing Miro boards into the tldraw canvas.
* Supports both JSON file upload and pasting JSON directly.
*/
import { useState, useCallback, useRef } from 'react'
import { useEditor } from 'tldraw'
import { importMiroJson, isValidMiroUrl, MiroImportResult } from '@/lib/miroImport'
interface MiroImportDialogProps {
isOpen: boolean
onClose: () => void
}
type ImportMethod = 'json-file' | 'json-paste'
export function MiroImportDialog({ isOpen, onClose }: MiroImportDialogProps) {
const editor = useEditor()
const fileInputRef = useRef<HTMLInputElement>(null)
const [importMethod, setImportMethod] = useState<ImportMethod>('json-file')
const [jsonText, setJsonText] = useState('')
const [isImporting, setIsImporting] = useState(false)
const [progress, setProgress] = useState({ stage: '', percent: 0 })
const [result, setResult] = useState<MiroImportResult | null>(null)
const [error, setError] = useState<string | null>(null)
const resetState = useCallback(() => {
setJsonText('')
setIsImporting(false)
setProgress({ stage: '', percent: 0 })
setResult(null)
setError(null)
}, [])
const handleClose = useCallback(() => {
resetState()
onClose()
}, [onClose, resetState])
const handleImport = useCallback(async (jsonString: string) => {
setIsImporting(true)
setError(null)
setResult(null)
try {
// Get current viewport center for import offset
const viewportBounds = editor.getViewportPageBounds()
const offset = {
x: viewportBounds.x + viewportBounds.w / 2,
y: viewportBounds.y + viewportBounds.h / 2,
}
const importResult = await importMiroJson(
jsonString,
{
migrateAssets: true,
offset,
},
{
onProgress: (stage, percent) => {
setProgress({ stage, percent: Math.round(percent * 100) })
},
}
)
setResult(importResult)
if (importResult.success && importResult.shapes.length > 0) {
// Create assets first
if (importResult.assets.length > 0) {
for (const asset of importResult.assets) {
try {
editor.createAssets([asset])
} catch (e) {
console.warn('Failed to create asset:', e)
}
}
}
// Create shapes
editor.createShapes(importResult.shapes)
// Select and zoom to imported shapes
const shapeIds = importResult.shapes.map((s: any) => s.id)
editor.setSelectedShapes(shapeIds)
editor.zoomToSelection()
}
} catch (e) {
console.error('Import error:', e)
setError(e instanceof Error ? e.message : 'Failed to import Miro board')
} finally {
setIsImporting(false)
}
}, [editor])
const handleFileSelect = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
try {
const text = await file.text()
await handleImport(text)
} catch (e) {
setError('Failed to read file')
}
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}, [handleImport])
const handlePasteImport = useCallback(() => {
if (!jsonText.trim()) {
setError('Please paste Miro JSON data')
return
}
handleImport(jsonText)
}, [jsonText, handleImport])
if (!isOpen) return null
return (
<div className="miro-import-overlay" onClick={handleClose}>
<div className="miro-import-dialog" onClick={(e) => e.stopPropagation()}>
<div className="miro-import-header">
<h2>Import from Miro</h2>
<button className="miro-import-close" onClick={handleClose}>
&times;
</button>
</div>
<div className="miro-import-content">
{/* Import Method Tabs */}
<div className="miro-import-tabs">
<button
className={`miro-import-tab ${importMethod === 'json-file' ? 'active' : ''}`}
onClick={() => setImportMethod('json-file')}
disabled={isImporting}
>
Upload JSON File
</button>
<button
className={`miro-import-tab ${importMethod === 'json-paste' ? 'active' : ''}`}
onClick={() => setImportMethod('json-paste')}
disabled={isImporting}
>
Paste JSON
</button>
</div>
{/* JSON File Upload */}
{importMethod === 'json-file' && (
<div className="miro-import-section">
<p className="miro-import-help">
Upload a JSON file exported from Miro using the{' '}
<a href="https://github.com/jolle/miro-export" target="_blank" rel="noopener noreferrer">
miro-export
</a>{' '}
CLI tool:
</p>
<pre className="miro-import-code">
npx miro-export -b YOUR_BOARD_ID -e json -o board.json
</pre>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileSelect}
disabled={isImporting}
className="miro-import-file-input"
/>
<button
className="miro-import-button"
onClick={() => fileInputRef.current?.click()}
disabled={isImporting}
>
Choose JSON File
</button>
</div>
)}
{/* JSON Paste */}
{importMethod === 'json-paste' && (
<div className="miro-import-section">
<p className="miro-import-help">
Paste your Miro board JSON data below:
</p>
<textarea
className="miro-import-textarea"
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
placeholder='[{"type":"sticky_note","id":"...","x":0,"y":0,...}]'
disabled={isImporting}
rows={10}
/>
<button
className="miro-import-button"
onClick={handlePasteImport}
disabled={isImporting || !jsonText.trim()}
>
Import
</button>
</div>
)}
{/* Progress */}
{isImporting && (
<div className="miro-import-progress">
<div className="miro-import-progress-bar">
<div
className="miro-import-progress-fill"
style={{ width: `${progress.percent}%` }}
/>
</div>
<p className="miro-import-progress-text">{progress.stage}</p>
</div>
)}
{/* Error */}
{error && (
<div className="miro-import-error">
<strong>Error:</strong> {error}
</div>
)}
{/* Result */}
{result && (
<div className={`miro-import-result ${result.success ? 'success' : 'failed'}`}>
{result.success ? (
<>
<p>Successfully imported {result.shapesCreated} shapes!</p>
{result.assetsUploaded > 0 && (
<p>Migrated {result.assetsUploaded} images to local storage.</p>
)}
{result.errors.length > 0 && (
<p className="miro-import-warnings">
Warnings: {result.errors.join(', ')}
</p>
)}
<button className="miro-import-button" onClick={handleClose}>
Done
</button>
</>
) : (
<>
<p>Import failed: {result.errors.join(', ')}</p>
<button className="miro-import-button" onClick={resetState}>
Try Again
</button>
</>
)}
</div>
)}
</div>
</div>
<style>{`
.miro-import-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.miro-import-dialog {
background: var(--color-panel, white);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.miro-import-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--color-divider, #eee);
}
.miro-import-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.miro-import-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text, #333);
padding: 0;
line-height: 1;
}
.miro-import-content {
padding: 20px;
}
.miro-import-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.miro-import-tab {
flex: 1;
padding: 10px 16px;
border: 1px solid var(--color-divider, #ddd);
background: var(--color-background, #f5f5f5);
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.miro-import-tab:hover:not(:disabled) {
background: var(--color-muted-1, #e0e0e0);
}
.miro-import-tab.active {
background: var(--color-primary, #2563eb);
color: white;
border-color: var(--color-primary, #2563eb);
}
.miro-import-tab:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.miro-import-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.miro-import-help {
margin: 0;
font-size: 14px;
color: var(--color-text-1, #666);
line-height: 1.5;
}
.miro-import-help a {
color: var(--color-primary, #2563eb);
}
.miro-import-code {
background: var(--color-background, #f5f5f5);
padding: 12px;
border-radius: 6px;
font-family: monospace;
font-size: 12px;
overflow-x: auto;
margin: 0;
}
.miro-import-file-input {
display: none;
}
.miro-import-textarea {
width: 100%;
padding: 12px;
border: 1px solid var(--color-divider, #ddd);
border-radius: 8px;
font-family: monospace;
font-size: 12px;
resize: vertical;
background: var(--color-background, white);
color: var(--color-text, #333);
}
.miro-import-textarea:focus {
outline: none;
border-color: var(--color-primary, #2563eb);
}
.miro-import-button {
padding: 12px 24px;
background: var(--color-primary, #2563eb);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.miro-import-button:hover:not(:disabled) {
background: var(--color-primary-dark, #1d4ed8);
}
.miro-import-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.miro-import-progress {
margin-top: 20px;
}
.miro-import-progress-bar {
height: 8px;
background: var(--color-background, #f0f0f0);
border-radius: 4px;
overflow: hidden;
}
.miro-import-progress-fill {
height: 100%;
background: var(--color-primary, #2563eb);
transition: width 0.3s ease;
}
.miro-import-progress-text {
margin: 8px 0 0;
font-size: 13px;
color: var(--color-text-1, #666);
text-align: center;
}
.miro-import-error {
margin-top: 16px;
padding: 12px;
background: #fee2e2;
color: #dc2626;
border-radius: 8px;
font-size: 14px;
}
.miro-import-result {
margin-top: 20px;
padding: 16px;
border-radius: 8px;
text-align: center;
}
.miro-import-result.success {
background: #dcfce7;
color: #16a34a;
}
.miro-import-result.failed {
background: #fee2e2;
color: #dc2626;
}
.miro-import-result p {
margin: 0 0 12px;
}
.miro-import-warnings {
font-size: 12px;
opacity: 0.8;
}
`}</style>
</div>
)
}
export default MiroImportDialog

View File

@ -0,0 +1,884 @@
/**
* Miro Integration Modal
*
* Allows users to import Miro boards into their canvas.
* Supports two methods:
* 1. Paste JSON from miro-export CLI tool (recommended for casual use)
* 2. Connect Miro API for direct imports (power users)
*/
import React, { useState, useCallback, useRef } from 'react';
import { useEditor } from 'tldraw';
import { importMiroJson } from '@/lib/miroImport';
import {
getMiroApiKey,
saveMiroApiKey,
removeMiroApiKey,
isMiroApiKeyConfigured,
extractMiroBoardId,
isValidMiroBoardUrl,
} from '@/lib/miroApiKey';
interface MiroIntegrationModalProps {
isOpen: boolean;
onClose: () => void;
username: string;
isDarkMode?: boolean;
}
type Tab = 'import' | 'api-setup' | 'help';
export function MiroIntegrationModal({
isOpen,
onClose,
username,
isDarkMode: _isDarkMode = false,
}: MiroIntegrationModalProps) {
const editor = useEditor();
const fileInputRef = useRef<HTMLInputElement>(null);
const [activeTab, setActiveTab] = useState<Tab>('import');
const [jsonText, setJsonText] = useState('');
const [boardUrl, setBoardUrl] = useState('');
const [apiKeyInput, setApiKeyInput] = useState('');
const [isImporting, setIsImporting] = useState(false);
const [progress, setProgress] = useState({ stage: '', percent: 0 });
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const hasApiKey = isMiroApiKeyConfigured(username);
const resetState = useCallback(() => {
setJsonText('');
setBoardUrl('');
setIsImporting(false);
setProgress({ stage: '', percent: 0 });
setError(null);
setSuccess(null);
}, []);
const handleClose = useCallback(() => {
resetState();
onClose();
}, [onClose, resetState]);
// Import from JSON string
const handleJsonImport = useCallback(async (json: string) => {
setIsImporting(true);
setError(null);
setSuccess(null);
try {
const viewportBounds = editor.getViewportPageBounds();
const offset = {
x: viewportBounds.x + viewportBounds.w / 2,
y: viewportBounds.y + viewportBounds.h / 2,
};
const result = await importMiroJson(
json,
{ migrateAssets: true, offset },
{
onProgress: (stage, percent) => {
setProgress({ stage, percent: Math.round(percent * 100) });
},
}
);
if (result.success && result.shapes.length > 0) {
// Create assets first
for (const asset of result.assets) {
try {
editor.createAssets([asset]);
} catch (e) {
console.warn('Failed to create asset:', e);
}
}
// Create shapes
editor.createShapes(result.shapes);
// Select and zoom to imported shapes
const shapeIds = result.shapes.map((s: any) => s.id);
editor.setSelectedShapes(shapeIds);
editor.zoomToSelection();
setSuccess(`Imported ${result.shapesCreated} shapes${result.assetsUploaded > 0 ? ` and ${result.assetsUploaded} images` : ''}!`);
// Auto-close after success
setTimeout(() => handleClose(), 2000);
} else {
setError(result.errors.join(', ') || 'No shapes found in the import');
}
} catch (e) {
console.error('Import error:', e);
setError(e instanceof Error ? e.message : 'Failed to import Miro board');
} finally {
setIsImporting(false);
}
}, [editor, handleClose]);
// Handle file upload
const handleFileSelect = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
await handleJsonImport(text);
} catch (e) {
setError('Failed to read file');
}
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [handleJsonImport]);
// Handle paste import
const handlePasteImport = useCallback(() => {
if (!jsonText.trim()) {
setError('Please paste Miro JSON data');
return;
}
handleJsonImport(jsonText);
}, [jsonText, handleJsonImport]);
// Save API key
const handleSaveApiKey = useCallback(() => {
if (!apiKeyInput.trim()) {
setError('Please enter your Miro API token');
return;
}
saveMiroApiKey(apiKeyInput.trim(), username);
setApiKeyInput('');
setSuccess('Miro API token saved!');
setTimeout(() => setSuccess(null), 2000);
}, [apiKeyInput, username]);
// Disconnect API
const handleDisconnectApi = useCallback(() => {
removeMiroApiKey(username);
setSuccess('Miro API disconnected');
setTimeout(() => setSuccess(null), 2000);
}, [username]);
if (!isOpen) return null;
return (
<div
className="miro-modal-overlay"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 999999,
isolation: 'isolate',
}}
onClick={(e) => {
if (e.target === e.currentTarget) handleClose();
}}
>
<div
className="miro-modal"
style={{
backgroundColor: 'var(--color-panel, #ffffff)',
borderRadius: '16px',
width: '520px',
maxWidth: '95vw',
maxHeight: '90vh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 25px 80px rgba(0, 0, 0, 0.5)',
position: 'relative',
zIndex: 1000000,
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div style={{
padding: '20px 24px',
borderBottom: '1px solid var(--color-panel-contrast)',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '10px',
background: 'linear-gradient(135deg, #FFD02F 0%, #F2CA00 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
}}>
📋
</div>
<div style={{ flex: 1 }}>
<h2 style={{ margin: 0, fontSize: '18px', fontWeight: 600, color: 'var(--color-text)' }}>
Import from Miro
</h2>
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-3)' }}>
Bring your Miro boards into the canvas
</p>
</div>
<button
onClick={handleClose}
style={{
background: '#f3f4f6',
border: '2px solid #e5e7eb',
borderRadius: '8px',
fontSize: '18px',
cursor: 'pointer',
color: '#6b7280',
padding: '6px 10px',
fontWeight: 600,
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#e5e7eb';
e.currentTarget.style.borderColor = '#d1d5db';
e.currentTarget.style.color = '#374151';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#f3f4f6';
e.currentTarget.style.borderColor = '#e5e7eb';
e.currentTarget.style.color = '#6b7280';
}}
>
×
</button>
</div>
{/* Tabs */}
<div style={{
display: 'flex',
borderBottom: '1px solid var(--color-panel-contrast)',
padding: '0 16px',
}}>
{[
{ id: 'import', label: 'Import Board' },
{ id: 'api-setup', label: 'API Setup' },
{ id: 'help', label: 'How It Works' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as Tab)}
style={{
padding: '12px 16px',
fontSize: '13px',
fontWeight: 500,
background: 'none',
border: 'none',
borderBottom: activeTab === tab.id ? '2px solid #FFD02F' : '2px solid transparent',
color: activeTab === tab.id ? 'var(--color-text)' : 'var(--color-text-3)',
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
{tab.label}
</button>
))}
</div>
{/* Content */}
<div style={{
padding: '20px 24px',
overflowY: 'auto',
flex: 1,
}}>
{/* Import Tab */}
{activeTab === 'import' && (
<div>
{/* Method 1: JSON Upload */}
<div style={{ marginBottom: '24px' }}>
<h3 style={{
margin: '0 0 8px 0',
fontSize: '14px',
fontWeight: 600,
color: 'var(--color-text)',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<span style={{
width: '20px',
height: '20px',
borderRadius: '50%',
background: '#FFD02F',
color: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
fontWeight: 700,
}}>1</span>
Upload JSON File
</h3>
<p style={{ margin: '0 0 12px 0', fontSize: '12px', color: 'var(--color-text-3)', lineHeight: 1.5 }}>
Export your board using the miro-export CLI, then upload the JSON file here.
</p>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isImporting}
style={{
width: '100%',
padding: '16px',
fontSize: '14px',
fontWeight: 600,
borderRadius: '8px',
border: '2px dashed #9ca3af',
background: '#f9fafb',
color: '#374151',
cursor: isImporting ? 'wait' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => {
if (!isImporting) {
e.currentTarget.style.borderColor = '#FFD02F';
e.currentTarget.style.background = '#fffbeb';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#9ca3af';
e.currentTarget.style.background = '#f9fafb';
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Choose JSON File
</button>
</div>
{/* Divider */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
margin: '20px 0',
}}>
<div style={{ flex: 1, height: '1px', background: 'var(--color-panel-contrast)' }} />
<span style={{ fontSize: '11px', color: 'var(--color-text-3)', textTransform: 'uppercase' }}>or</span>
<div style={{ flex: 1, height: '1px', background: 'var(--color-panel-contrast)' }} />
</div>
{/* Method 2: Paste JSON */}
<div>
<h3 style={{
margin: '0 0 8px 0',
fontSize: '14px',
fontWeight: 600,
color: 'var(--color-text)',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<span style={{
width: '20px',
height: '20px',
borderRadius: '50%',
background: '#FFD02F',
color: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
fontWeight: 700,
}}>2</span>
Paste JSON
</h3>
<textarea
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
placeholder='[{"type":"sticky_note","id":"..."}]'
disabled={isImporting}
style={{
width: '100%',
height: '120px',
padding: '12px',
fontSize: '12px',
fontFamily: 'monospace',
borderRadius: '8px',
border: '2px solid #d1d5db',
background: '#ffffff',
color: '#1f2937',
resize: 'vertical',
marginBottom: '12px',
outline: 'none',
boxSizing: 'border-box',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = '#FFD02F';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(255, 208, 47, 0.2)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#d1d5db';
e.currentTarget.style.boxShadow = 'none';
}}
/>
<button
onClick={handlePasteImport}
disabled={isImporting || !jsonText.trim()}
style={{
width: '100%',
padding: '14px 16px',
fontSize: '14px',
fontWeight: 600,
borderRadius: '8px',
border: jsonText.trim() ? '2px solid #e6b800' : '2px solid #d1d5db',
background: jsonText.trim() ? 'linear-gradient(135deg, #FFD02F 0%, #F2CA00 100%)' : '#f3f4f6',
color: jsonText.trim() ? '#000' : '#9ca3af',
cursor: isImporting || !jsonText.trim() ? 'not-allowed' : 'pointer',
boxShadow: jsonText.trim() ? '0 2px 8px rgba(255, 208, 47, 0.3)' : 'none',
transition: 'all 0.15s ease',
}}
>
{isImporting ? 'Importing...' : 'Import to Canvas'}
</button>
</div>
{/* Progress */}
{isImporting && (
<div style={{ marginTop: '16px' }}>
<div style={{
height: '4px',
background: 'var(--color-muted-2)',
borderRadius: '2px',
overflow: 'hidden',
}}>
<div style={{
height: '100%',
width: `${progress.percent}%`,
background: '#FFD02F',
transition: 'width 0.3s',
}} />
</div>
<p style={{ margin: '8px 0 0', fontSize: '12px', color: 'var(--color-text-3)', textAlign: 'center' }}>
{progress.stage}
</p>
</div>
)}
{/* Error/Success messages */}
{error && (
<div style={{
marginTop: '16px',
padding: '12px',
borderRadius: '8px',
background: '#fee2e2',
color: '#dc2626',
fontSize: '13px',
}}>
{error}
</div>
)}
{success && (
<div style={{
marginTop: '16px',
padding: '12px',
borderRadius: '8px',
background: '#dcfce7',
color: '#16a34a',
fontSize: '13px',
}}>
{success}
</div>
)}
</div>
)}
{/* API Setup Tab */}
{activeTab === 'api-setup' && (
<div>
<div style={{
padding: '16px',
borderRadius: '8px',
background: hasApiKey ? 'rgba(34, 197, 94, 0.1)' : 'var(--color-muted-1)',
marginBottom: '20px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}>
<span style={{ fontSize: '24px' }}>{hasApiKey ? '✅' : '🔑'}</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
{hasApiKey ? 'Miro API Connected' : 'Connect Miro API'}
</div>
<div style={{ fontSize: '12px', color: 'var(--color-text-3)' }}>
{hasApiKey
? 'You can import boards directly from Miro'
: 'For power users who want direct board imports'}
</div>
</div>
</div>
{!hasApiKey ? (
<>
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
fontSize: '13px',
fontWeight: 500,
color: 'var(--color-text)',
marginBottom: '8px',
}}>
Miro API Access Token
</label>
<input
type="password"
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
placeholder="Enter your Miro access token..."
style={{
width: '100%',
padding: '14px',
fontSize: '14px',
borderRadius: '8px',
border: '2px solid #d1d5db',
background: '#ffffff',
color: '#1f2937',
outline: 'none',
boxSizing: 'border-box',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = '#FFD02F';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(255, 208, 47, 0.2)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#d1d5db';
e.currentTarget.style.boxShadow = 'none';
}}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveApiKey();
}}
/>
</div>
<button
onClick={handleSaveApiKey}
style={{
width: '100%',
padding: '14px 16px',
fontSize: '14px',
fontWeight: 600,
borderRadius: '8px',
border: '2px solid #e6b800',
background: 'linear-gradient(135deg, #FFD02F 0%, #F2CA00 100%)',
color: '#000',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(255, 208, 47, 0.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
}}
>
Save API Token
</button>
</>
) : (
<button
onClick={handleDisconnectApi}
style={{
width: '100%',
padding: '14px 16px',
fontSize: '14px',
fontWeight: 600,
borderRadius: '8px',
border: '2px solid #fca5a5',
background: '#fee2e2',
color: '#dc2626',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#fecaca';
e.currentTarget.style.borderColor = '#f87171';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#fee2e2';
e.currentTarget.style.borderColor = '#fca5a5';
}}
>
Disconnect Miro API
</button>
)}
{/* API Setup Instructions */}
<div style={{
marginTop: '24px',
padding: '16px',
borderRadius: '8px',
background: 'var(--color-muted-1)',
border: '1px solid var(--color-panel-contrast)',
}}>
<h4 style={{ margin: '0 0 12px', fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>
How to get your Miro API Token
</h4>
<ol style={{
margin: 0,
paddingLeft: '20px',
fontSize: '12px',
color: 'var(--color-text-3)',
lineHeight: 1.8,
}}>
<li>Go to <a href="https://miro.com/app/settings/user-profile/apps" target="_blank" rel="noopener noreferrer" style={{ color: '#FFD02F' }}>Miro Developer Settings</a></li>
<li>Click "Create new app"</li>
<li>Give it a name (e.g., "Canvas Import")</li>
<li>Under "Permissions", enable:
<ul style={{ margin: '4px 0', paddingLeft: '16px' }}>
<li>boards:read</li>
<li>boards:write (optional)</li>
</ul>
</li>
<li>Click "Install app and get OAuth token"</li>
<li>Select your team and authorize</li>
<li>Copy the access token and paste it above</li>
</ol>
<p style={{ margin: '12px 0 0', fontSize: '11px', color: 'var(--color-text-3)' }}>
Note: This is a one-time setup. Your token is stored locally and never sent to our servers.
</p>
</div>
{error && (
<div style={{
marginTop: '16px',
padding: '12px',
borderRadius: '8px',
background: '#fee2e2',
color: '#dc2626',
fontSize: '13px',
}}>
{error}
</div>
)}
{success && (
<div style={{
marginTop: '16px',
padding: '12px',
borderRadius: '8px',
background: '#dcfce7',
color: '#16a34a',
fontSize: '13px',
}}>
{success}
</div>
)}
</div>
)}
{/* Help Tab */}
{activeTab === 'help' && (
<div>
<div style={{
padding: '16px',
borderRadius: '8px',
background: 'linear-gradient(135deg, rgba(255, 208, 47, 0.1) 0%, rgba(242, 202, 0, 0.1) 100%)',
border: '1px solid rgba(255, 208, 47, 0.3)',
marginBottom: '20px',
}}>
<h3 style={{ margin: '0 0 8px', fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
Quick Start (Recommended)
</h3>
<p style={{ margin: 0, fontSize: '13px', color: 'var(--color-text-3)', lineHeight: 1.6 }}>
The easiest way to import a Miro board is using the <code style={{
background: 'var(--color-muted-2)',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
}}>miro-export</code> CLI tool. This runs on your computer and exports your board as JSON.
</p>
</div>
<h4 style={{ margin: '0 0 12px', fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
Step-by-Step Instructions
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* Step 1 */}
<div style={{ display: 'flex', gap: '12px' }}>
<div style={{
width: '28px',
height: '28px',
borderRadius: '50%',
background: '#FFD02F',
color: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '13px',
fontWeight: 700,
flexShrink: 0,
}}>1</div>
<div>
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)', marginBottom: '4px' }}>
Find your Miro Board ID
</div>
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-3)', lineHeight: 1.5 }}>
Open your board in Miro. The Board ID is in the URL:
</p>
<code style={{
display: 'block',
margin: '8px 0',
padding: '8px 12px',
background: 'var(--color-muted-1)',
borderRadius: '6px',
fontSize: '11px',
color: 'var(--color-text)',
wordBreak: 'break-all',
}}>
miro.com/app/board/<span style={{ background: '#FFD02F', color: '#000', padding: '0 4px', borderRadius: '2px' }}>uXjVLxxxxxxxx=</span>/
</code>
</div>
</div>
{/* Step 2 */}
<div style={{ display: 'flex', gap: '12px' }}>
<div style={{
width: '28px',
height: '28px',
borderRadius: '50%',
background: '#FFD02F',
color: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '13px',
fontWeight: 700,
flexShrink: 0,
}}>2</div>
<div>
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)', marginBottom: '4px' }}>
Run the Export Command
</div>
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-3)', lineHeight: 1.5 }}>
Open your terminal and run:
</p>
<code style={{
display: 'block',
margin: '8px 0',
padding: '8px 12px',
background: 'var(--color-muted-1)',
borderRadius: '6px',
fontSize: '11px',
color: 'var(--color-text)',
wordBreak: 'break-all',
}}>
npx miro-export -b YOUR_BOARD_ID -e json -o board.json
</code>
<p style={{ margin: '4px 0 0', fontSize: '11px', color: 'var(--color-text-3)' }}>
This will open Miro in a browser window. Sign in if prompted.
</p>
</div>
</div>
{/* Step 3 */}
<div style={{ display: 'flex', gap: '12px' }}>
<div style={{
width: '28px',
height: '28px',
borderRadius: '50%',
background: '#FFD02F',
color: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '13px',
fontWeight: 700,
flexShrink: 0,
}}>3</div>
<div>
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)', marginBottom: '4px' }}>
Upload the JSON
</div>
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-3)', lineHeight: 1.5 }}>
Go to the "Import Board" tab and upload your <code style={{
background: 'var(--color-muted-2)',
padding: '1px 4px',
borderRadius: '3px',
fontSize: '11px',
}}>board.json</code> file. That's it!
</p>
</div>
</div>
</div>
{/* What Gets Imported */}
<div style={{
marginTop: '24px',
padding: '16px',
borderRadius: '8px',
background: 'var(--color-muted-1)',
}}>
<h4 style={{ margin: '0 0 12px', fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>
What Gets Imported
</h4>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '8px',
fontSize: '12px',
}}>
{[
{ icon: '📝', label: 'Sticky Notes' },
{ icon: '🔷', label: 'Shapes' },
{ icon: '📄', label: 'Text' },
{ icon: '🖼️', label: 'Images' },
{ icon: '🔗', label: 'Connectors' },
{ icon: '🖼️', label: 'Frames' },
{ icon: '🃏', label: 'Cards' },
].map((item) => (
<div key={item.label} style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
color: 'var(--color-text-3)',
}}>
<span style={{ fontSize: '14px' }}>{item.icon}</span>
{item.label}
</div>
))}
</div>
<p style={{
margin: '12px 0 0',
fontSize: '11px',
color: 'var(--color-text-3)',
fontStyle: 'italic',
}}>
Images are automatically downloaded and stored locally, so they'll persist even if you lose Miro access.
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}
export default MiroIntegrationModal;

View File

@ -82,58 +82,48 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
// Save vault to Automerge store
const saveVaultToAutomerge = (vault: ObsidianVault) => {
if (!automergeHandle) {
console.warn('⚠️ Automerge handle not available, saving to localStorage only')
try {
const vaultRecord = importer.vaultToRecord(vault)
localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({
...vaultRecord,
lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported
}))
console.log('🔧 Saved vault to localStorage (Automerge handle not available):', vaultRecord.id)
} catch (localStorageError) {
console.warn('⚠️ Could not save vault to localStorage:', localStorageError)
console.warn('Could not save vault to localStorage:', localStorageError)
}
return
}
try {
const vaultRecord = importer.vaultToRecord(vault)
// Save directly to Automerge, bypassing TLDraw store validation
// This allows us to save custom record types like obsidian_vault
automergeHandle.change((doc: any) => {
// Ensure doc.store exists
if (!doc.store) {
doc.store = {}
}
// Save the vault record directly to Automerge store
// Convert Date to ISO string for serialization
const recordToSave = {
...vaultRecord,
lastImported: vaultRecord.lastImported instanceof Date
? vaultRecord.lastImported.toISOString()
lastImported: vaultRecord.lastImported instanceof Date
? vaultRecord.lastImported.toISOString()
: vaultRecord.lastImported
}
doc.store[vaultRecord.id] = recordToSave
})
console.log('🔧 Saved vault to Automerge:', vaultRecord.id)
// Also save to localStorage as a backup
try {
localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({
...vaultRecord,
lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported
}))
console.log('🔧 Saved vault to localStorage as backup:', vaultRecord.id)
} catch (localStorageError) {
console.warn('⚠️ Could not save vault to localStorage:', localStorageError)
// Silent fail for backup
}
} catch (error) {
console.error('❌ Error saving vault to Automerge:', error)
// Don't throw - allow vault loading to continue even if saving fails
console.error('Error saving vault to Automerge:', error)
// Try localStorage as fallback
try {
const vaultRecord = importer.vaultToRecord(vault)
@ -141,9 +131,8 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
...vaultRecord,
lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported
}))
console.log('🔧 Saved vault to localStorage as fallback:', vaultRecord.id)
} catch (localStorageError) {
console.warn('⚠️ Could not save vault to localStorage:', localStorageError)
console.warn('Could not save vault to localStorage:', localStorageError)
}
}
}
@ -157,10 +146,8 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
if (doc && doc.store) {
const vaultId = `obsidian_vault:${vaultName}`
const vaultRecord = doc.store[vaultId] as ObsidianVaultRecord | undefined
if (vaultRecord && vaultRecord.typeName === 'obsidian_vault') {
console.log('🔧 Loaded vault from Automerge:', vaultId)
// Convert date string back to Date object if needed
const recordCopy = JSON.parse(JSON.stringify(vaultRecord))
if (typeof recordCopy.lastImported === 'string') {
recordCopy.lastImported = new Date(recordCopy.lastImported)
@ -169,18 +156,16 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
}
}
} catch (error) {
console.warn('⚠️ Could not load vault from Automerge:', error)
// Fall through to localStorage
}
}
// Try localStorage as fallback
try {
const cached = localStorage.getItem(`obsidian_vault_cache:${vaultName}`)
if (cached) {
const vaultRecord = JSON.parse(cached) as ObsidianVaultRecord
if (vaultRecord && vaultRecord.typeName === 'obsidian_vault') {
console.log('🔧 Loaded vault from localStorage cache:', vaultName)
// Convert date string back to Date object
if (typeof vaultRecord.lastImported === 'string') {
vaultRecord.lastImported = new Date(vaultRecord.lastImported)
}
@ -188,9 +173,9 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
}
}
} catch (e) {
console.warn('⚠️ Could not load vault from localStorage:', e)
// Silent fail
}
return null
}
@ -198,47 +183,31 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
useEffect(() => {
// Prevent multiple loads if already loading or already loaded once
if (isLoadingVault || hasLoadedOnce) {
console.log('🔧 ObsidianVaultBrowser: Skipping load - already loading or loaded once')
return
}
console.log('🔧 ObsidianVaultBrowser: Component mounted, checking user identity for vault...')
console.log('🔧 Current session vault data:', {
path: session.obsidianVaultPath,
name: session.obsidianVaultName,
authed: session.authed,
username: session.username
})
// FIRST PRIORITY: Try to load from user's configured vault in session (user identity)
if (session.obsidianVaultPath && session.obsidianVaultPath !== 'folder-selected') {
console.log('✅ Found configured vault in user identity:', session.obsidianVaultPath)
console.log('🔧 Loading vault from user identity...')
// First try to load from Automerge cache for faster loading
if (session.obsidianVaultName) {
const cachedVault = loadVaultFromAutomerge(session.obsidianVaultName)
if (cachedVault) {
console.log('✅ Loaded vault from Automerge cache')
setVault(cachedVault)
setIsLoading(false)
setHasLoadedOnce(true)
return
}
}
// If not in cache, load from source (Quartz URL or local path)
console.log('🔧 Loading vault from source:', session.obsidianVaultPath)
loadVault(session.obsidianVaultPath)
} else if (session.obsidianVaultPath === 'folder-selected' && session.obsidianVaultName) {
console.log('🔧 Vault was previously selected via folder picker, showing reselect interface')
// For folder-selected vaults, we can't reload them, so show a special reselect interface
setVault(null)
setShowFolderReselect(true)
setIsLoading(false)
setHasLoadedOnce(true)
} else {
console.log('⚠️ No vault configured in user identity, showing empty state...')
setVault(null)
setIsLoading(false)
setHasLoadedOnce(true)
@ -250,30 +219,28 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
// Check if values actually changed (not just object reference)
const vaultPathChanged = previousVaultPathRef.current !== session.obsidianVaultPath
const vaultNameChanged = previousVaultNameRef.current !== session.obsidianVaultName
// If vault is already loaded and values haven't changed, don't do anything
if (hasLoadedOnce && !vaultPathChanged && !vaultNameChanged) {
return // Already loaded and nothing changed, no need to reload
return
}
// Update refs to current values
previousVaultPathRef.current = session.obsidianVaultPath
previousVaultNameRef.current = session.obsidianVaultName
// Only proceed if values actually changed and we haven't loaded yet
if (!vaultPathChanged && !vaultNameChanged) {
return // Values haven't changed, no need to reload
return
}
if (hasLoadedOnce || isLoadingVault) {
return // Don't reload if we've already loaded or are currently loading
return
}
if (session.obsidianVaultPath && session.obsidianVaultPath !== 'folder-selected') {
console.log('🔧 Session vault path changed, loading vault:', session.obsidianVaultPath)
loadVault(session.obsidianVaultPath)
} else if (session.obsidianVaultPath === 'folder-selected' && session.obsidianVaultName) {
console.log('🔧 Session shows folder-selected vault, showing reselect interface')
setVault(null)
setShowFolderReselect(true)
setIsLoading(false)
@ -284,7 +251,6 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
// Auto-open folder picker if requested
useEffect(() => {
if (autoOpenFolderPicker) {
console.log('Auto-opening folder picker...')
handleFolderPicker()
}
}, [autoOpenFolderPicker])
@ -312,7 +278,6 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
console.log('🔧 ESC key pressed, closing vault browser')
onClose()
}
}
@ -326,57 +291,38 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
const loadVault = async (path?: string) => {
// Prevent concurrent loading operations
if (isLoadingVault) {
console.log('🔧 loadVault: Already loading, skipping concurrent request')
return
}
setIsLoadingVault(true)
setIsLoading(true)
setError(null)
try {
if (path) {
// Check if it's a Quartz URL
if (path.startsWith('http') || path.includes('quartz') || path.includes('.xyz') || path.includes('.com')) {
// Load from Quartz URL - always get latest data
console.log('🔧 Loading Quartz vault from URL (getting latest data):', path)
const loadedVault = await importer.importFromQuartzUrl(path)
console.log('Loaded Quartz vault from URL:', loadedVault)
setVault(loadedVault)
setShowVaultInput(false)
setShowFolderReselect(false)
// Save the vault path and name to user session
console.log('🔧 Saving Quartz vault to session:', { path, name: loadedVault.name })
updateSession({
updateSession({
obsidianVaultPath: path,
obsidianVaultName: loadedVault.name
})
console.log('🔧 Quartz vault saved to session successfully')
// Save vault to Automerge for persistence
saveVaultToAutomerge(loadedVault)
} else {
// Load from local directory
console.log('🔧 Loading vault from local directory:', path)
const loadedVault = await importer.importFromDirectory(path)
console.log('Loaded vault from path:', loadedVault)
setVault(loadedVault)
setShowVaultInput(false)
setShowFolderReselect(false)
// Save the vault path and name to user session
console.log('🔧 Saving vault to session:', { path, name: loadedVault.name })
updateSession({
updateSession({
obsidianVaultPath: path,
obsidianVaultName: loadedVault.name
})
console.log('🔧 Vault saved to session successfully')
// Save vault to Automerge for persistence
saveVaultToAutomerge(loadedVault)
}
} else {
// No vault configured - show empty state
console.log('No vault configured, showing empty state...')
setVault(null)
setShowVaultInput(false)
}
@ -384,8 +330,6 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
console.error('Failed to load vault:', err)
setError('Failed to load Obsidian vault. Please try again.')
setVault(null)
// Don't show vault input if user already has a vault configured
// Only show vault input if this is a fresh attempt
if (!session.obsidianVaultPath) {
setShowVaultInput(true)
}
@ -401,11 +345,8 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
setError('Please enter a vault path or URL')
return
}
console.log('📝 Submitting vault path:', vaultPath.trim(), 'Method:', inputMethod)
if (inputMethod === 'quartz') {
// Handle Quartz URL
try {
setIsLoading(true)
setError(null)
@ -413,70 +354,49 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
setVault(loadedVault)
setShowVaultInput(false)
setShowFolderReselect(false)
// Save Quartz vault to user identity (session)
console.log('🔧 Saving Quartz vault to user identity:', {
path: vaultPath.trim(),
name: loadedVault.name
})
updateSession({
updateSession({
obsidianVaultPath: vaultPath.trim(),
obsidianVaultName: loadedVault.name
})
} catch (error) {
console.error('Error loading Quartz vault:', error)
console.error('Error loading Quartz vault:', error)
setError(error instanceof Error ? error.message : 'Failed to load Quartz vault')
} finally {
setIsLoading(false)
}
} else {
// Handle regular vault path (local folder or URL)
loadVault(vaultPath.trim())
}
}
const handleFolderPicker = async () => {
console.log('📁 Folder picker button clicked')
if (!('showDirectoryPicker' in window)) {
setError('File System Access API is not supported in this browser. Please use "Enter Path" instead.')
setShowVaultInput(true)
return
}
try {
setIsLoading(true)
setError(null)
console.log('📁 Opening directory picker...')
const loadedVault = await importer.importFromFileSystem()
console.log('✅ Vault loaded from folder picker:', loadedVault.name)
setVault(loadedVault)
setShowVaultInput(false)
setShowFolderReselect(false)
// Note: We can't get the actual path from importFromFileSystem,
// but we can save a flag that a folder was selected
console.log('🔧 Saving folder-selected vault to user identity:', {
path: 'folder-selected',
name: loadedVault.name
})
updateSession({
updateSession({
obsidianVaultPath: 'folder-selected',
obsidianVaultName: loadedVault.name
})
console.log('✅ Folder-selected vault saved to user identity successfully')
// Save vault to Automerge for persistence
saveVaultToAutomerge(loadedVault)
} catch (err) {
console.error('❌ Failed to load vault from folder picker:', err)
if ((err as any).name === 'AbortError') {
// User cancelled the folder picker
console.log('📁 User cancelled folder picker')
setError(null) // Don't show error for cancellation
setError(null)
} else {
console.error('Failed to load vault from folder picker:', err)
setError('Failed to load Obsidian vault. Please try again.')
}
} finally {
@ -514,45 +434,27 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
const folderNotes = importer.getAllNotesFromTree(folder)
obs_notes = obs_notes.filter(note => folderNotes.some(folderNote => folderNote.id === note.id))
}
} else if (viewMode === 'tree' && selectedFolder === null) {
// In tree view but no folder selected, show all notes
// This allows users to see all notes when no specific folder is selected
}
// Debug logging
console.log('Search query:', debouncedSearchQuery)
console.log('View mode:', viewMode)
console.log('Selected folder:', selectedFolder)
console.log('Total notes:', vault.obs_notes.length)
console.log('Filtered notes:', obs_notes.length)
return obs_notes
}, [vault, debouncedSearchQuery, viewMode, selectedFolder, folderTree, importer])
// Listen for trigger-obsnote-creation event from CustomToolbar
useEffect(() => {
const handleTriggerCreation = () => {
console.log('🎯 ObsidianVaultBrowser: Received trigger-obsnote-creation event')
if (selectedNotes.size > 0) {
// Create shapes from currently selected notes
const selectedObsNotes = filteredObsNotes.filter(obs_note => selectedNotes.has(obs_note.id))
console.log('🎯 Creating shapes from selected notes:', selectedObsNotes.length)
onObsNotesSelect(selectedObsNotes)
} else {
// If no notes are selected, select all visible notes
const allVisibleNotes = filteredObsNotes
if (allVisibleNotes.length > 0) {
console.log('🎯 No notes selected, creating shapes from all visible notes:', allVisibleNotes.length)
onObsNotesSelect(allVisibleNotes)
} else {
console.log('🎯 No notes available to create shapes from')
}
}
}
window.addEventListener('trigger-obsnote-creation', handleTriggerCreation as EventListener)
return () => {
window.removeEventListener('trigger-obsnote-creation', handleTriggerCreation as EventListener)
}
@ -663,7 +565,6 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
}
const handleObsNoteClick = (obs_note: ObsidianObsNote) => {
console.log('🎯 ObsidianVaultBrowser: handleObsNoteClick called with:', obs_note)
onObsNoteSelect(obs_note)
}
@ -679,7 +580,6 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
const handleBulkImport = () => {
const selectedObsNotes = filteredObsNotes.filter(obs_note => selectedNotes.has(obs_note.id))
console.log('🎯 ObsidianVaultBrowser: handleBulkImport called with:', selectedObsNotes.length, 'notes')
onObsNotesSelect(selectedObsNotes)
setSelectedNotes(new Set())
}
@ -730,13 +630,11 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
const handleDisconnectVault = () => {
// Clear the vault from session
updateSession({
updateSession({
obsidianVaultPath: undefined,
obsidianVaultName: undefined
})
// Reset component state
setVault(null)
setSearchQuery('')
setDebouncedSearchQuery('')
@ -746,8 +644,6 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
setError(null)
setHasLoadedOnce(false)
setIsLoadingVault(false)
console.log('🔧 Vault disconnected successfully')
}
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
@ -841,24 +737,19 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
<h3>Load Obsidian Vault</h3>
<p>Choose how you'd like to load your Obsidian vault:</p>
<div className="vault-options">
<button
onClick={() => {
console.log('📁 Select Folder button clicked')
handleFolderPicker()
}}
<button
onClick={handleFolderPicker}
className="load-vault-button primary"
>
📁 Select Folder
</button>
<button
<button
onClick={() => {
console.log('📝 Enter Path button clicked')
// Pre-populate with session vault path if available
if (session.obsidianVaultPath && session.obsidianVaultPath !== 'folder-selected') {
setVaultPath(session.obsidianVaultPath)
}
setShowVaultInput(true)
}}
}}
className="load-vault-button secondary"
>
📝 Enter Path

View File

@ -0,0 +1,67 @@
// Connection status for UI display (maps from ConnectionState)
export type ConnectionStatus = 'online' | 'offline' | 'syncing'
interface OfflineIndicatorProps {
connectionStatus: ConnectionStatus
isOfflineReady: boolean
}
export function OfflineIndicator({ connectionStatus, isOfflineReady }: OfflineIndicatorProps) {
// Don't show indicator when online and everything is working normally
if (connectionStatus === 'online') {
return null
}
const getStatusConfig = () => {
switch (connectionStatus) {
case 'offline':
return {
icon: '📴',
text: isOfflineReady ? 'Offline (changes saved locally)' : 'Offline',
bgColor: '#fef3c7', // warm yellow
textColor: '#92400e',
borderColor: '#f59e0b'
}
case 'syncing':
return {
icon: '🔄',
text: 'Syncing...',
bgColor: '#dbeafe', // light blue
textColor: '#1e40af',
borderColor: '#3b82f6'
}
default:
return null
}
}
const config = getStatusConfig()
if (!config) return null
return (
<div
style={{
position: 'fixed',
bottom: '16px',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: config.bgColor,
color: config.textColor,
padding: '8px 16px',
borderRadius: '8px',
border: `1px solid ${config.borderColor}`,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
display: 'flex',
alignItems: 'center',
gap: '8px',
zIndex: 9999,
fontSize: '14px',
fontFamily: 'system-ui, -apple-system, sans-serif',
pointerEvents: 'none'
}}
>
<span style={{ fontSize: '16px' }}>{config.icon}</span>
<span>{config.text}</span>
</div>
)
}

View File

@ -0,0 +1,22 @@
import { useEditor } from 'tldraw'
import { usePrivateWorkspace } from '../hooks/usePrivateWorkspace'
/**
* Component that manages the Private Workspace zone for Google Export data.
* Listens for 'add-google-items-to-canvas' events and creates items in the workspace.
*
* Must be rendered inside a Tldraw context.
*/
export function PrivateWorkspaceManager() {
const editor = useEditor()
// This hook handles:
// - Creating/showing the private workspace zone
// - Listening for 'add-google-items-to-canvas' events
// - Adding items to the workspace when triggered
usePrivateWorkspace({ editor })
// This component doesn't render anything visible
// It just manages the workspace logic
return null
}

View File

@ -0,0 +1,632 @@
import React, { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useParams } from 'react-router-dom';
import { QRCodeSVG } from 'qrcode.react';
interface ShareBoardButtonProps {
className?: string;
}
type PermissionType = 'view' | 'edit' | 'admin';
const PERMISSION_LABELS: Record<PermissionType, { label: string; description: string; color: string }> = {
view: { label: 'View', description: 'Can view but not edit', color: '#6b7280' },
edit: { label: 'Edit', description: 'Can view and edit', color: '#3b82f6' },
admin: { label: 'Admin', description: 'Full control', color: '#10b981' },
};
const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) => {
const { slug } = useParams<{ slug: string }>();
const [showDropdown, setShowDropdown] = useState(false);
// Detect dark mode
const [isDarkMode, setIsDarkMode] = useState(
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
);
useEffect(() => {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
setIsDarkMode(document.documentElement.classList.contains('dark'));
}
});
});
observer.observe(document.documentElement, { attributes: true });
return () => observer.disconnect();
}, []);
const [copied, setCopied] = useState(false);
const [permission, setPermission] = useState<PermissionType>('edit');
const [nfcStatus, setNfcStatus] = useState<'idle' | 'writing' | 'success' | 'error' | 'unsupported'>('idle');
const [nfcMessage, setNfcMessage] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const [inviteInput, setInviteInput] = useState('');
const [inviteStatus, setInviteStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const dropdownRef = useRef<HTMLDivElement>(null);
const dropdownMenuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null);
const boardSlug = slug || 'mycofi33';
const boardUrl = `${window.location.origin}/board/${boardSlug}`;
// Update dropdown position when it opens
useEffect(() => {
if (showDropdown && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + 8,
right: window.innerWidth - rect.right,
});
}
}, [showDropdown]);
// Generate URL with permission parameter
const getShareUrl = () => {
const url = new URL(boardUrl);
url.searchParams.set('access', permission);
return url.toString();
};
// Check NFC support on mount
useEffect(() => {
if (!('NDEFReader' in window)) {
setNfcStatus('unsupported');
}
}, []);
// Close dropdown when clicking outside or pressing ESC
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
// Check if click is inside trigger OR the portal dropdown menu
const isInsideTrigger = dropdownRef.current && dropdownRef.current.contains(target);
const isInsideMenu = dropdownMenuRef.current && dropdownMenuRef.current.contains(target);
if (!isInsideTrigger && !isInsideMenu) {
setShowDropdown(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
setShowDropdown(false);
}
};
if (showDropdown) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown, true);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [showDropdown]);
const handleCopyUrl = async () => {
try {
await navigator.clipboard.writeText(getShareUrl());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy URL:', err);
}
};
const handleInvite = async () => {
if (!inviteInput.trim()) return;
setInviteStatus('sending');
try {
// TODO: Implement actual invite API call
// For now, simulate sending invite
await new Promise(resolve => setTimeout(resolve, 1000));
setInviteStatus('sent');
setInviteInput('');
setTimeout(() => setInviteStatus('idle'), 3000);
} catch (err) {
console.error('Failed to send invite:', err);
setInviteStatus('error');
setTimeout(() => setInviteStatus('idle'), 3000);
}
};
const handleNfcWrite = async () => {
if (!('NDEFReader' in window)) {
setNfcStatus('unsupported');
setNfcMessage('NFC is not supported on this device');
return;
}
try {
setNfcStatus('writing');
setNfcMessage('Hold your NFC tag near the device...');
const ndef = new (window as any).NDEFReader();
await ndef.write({
records: [
{ recordType: "url", data: getShareUrl() }
]
});
setNfcStatus('success');
setNfcMessage('Board URL written to NFC tag!');
setTimeout(() => {
setNfcStatus('idle');
setNfcMessage('');
}, 3000);
} catch (err: any) {
console.error('NFC write error:', err);
setNfcStatus('error');
if (err.name === 'NotAllowedError') {
setNfcMessage('NFC permission denied. Please allow NFC access.');
} else if (err.name === 'NotSupportedError') {
setNfcMessage('NFC is not supported on this device');
} else {
setNfcMessage(`Failed to write NFC tag: ${err.message || 'Unknown error'}`);
}
}
};
// Detect if we're in share-panel (compact) vs toolbar (full button)
const isCompact = className.includes('share-panel-btn');
if (isCompact) {
// Icon-only version for the top-right share panel with dropdown
return (
<div ref={dropdownRef} style={{ pointerEvents: 'all' }}>
<button
ref={triggerRef}
onClick={() => setShowDropdown(!showDropdown)}
className={`share-board-button ${className}`}
title="Invite others to this board"
style={{
background: showDropdown ? 'var(--color-muted-2)' : 'none',
border: 'none',
padding: '6px',
cursor: 'pointer',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-1)',
opacity: showDropdown ? 1 : 0.7,
transition: 'opacity 0.15s, background 0.15s',
pointerEvents: 'all',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1';
e.currentTarget.style.background = 'var(--color-muted-2)';
}}
onMouseLeave={(e) => {
if (!showDropdown) {
e.currentTarget.style.opacity = '0.7';
e.currentTarget.style.background = 'none';
}
}}
>
{/* User with plus icon (invite/add person) */}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{/* User outline */}
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
{/* Plus sign */}
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="16" y1="11" x2="22" y2="11" />
</svg>
</button>
{/* Dropdown - rendered via portal to break out of parent container */}
{showDropdown && dropdownPosition && createPortal(
<div
ref={dropdownMenuRef}
style={{
position: 'fixed',
top: dropdownPosition.top,
right: dropdownPosition.right,
width: '340px',
background: isDarkMode ? '#2d2d2d' : '#ffffff',
backgroundColor: isDarkMode ? '#2d2d2d' : '#ffffff',
backdropFilter: 'none',
opacity: 1,
border: `1px solid ${isDarkMode ? '#404040' : '#e5e5e5'}`,
borderRadius: '12px',
boxShadow: isDarkMode ? '0 8px 32px rgba(0,0,0,0.5)' : '0 8px 32px rgba(0,0,0,0.2)',
zIndex: 100000,
overflow: 'hidden',
pointerEvents: 'all',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
}}
onWheel={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{/* Compact Header */}
<div style={{
padding: '12px 14px',
borderBottom: '1px solid var(--color-panel-contrast)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px' }}>👥</span> Share Board
</span>
<button
onClick={() => setShowDropdown(false)}
onPointerDown={(e) => e.stopPropagation()}
style={{
background: 'var(--color-muted-2)',
border: 'none',
cursor: 'pointer',
padding: '4px 8px',
color: 'var(--color-text-3)',
fontSize: '11px',
fontFamily: 'inherit',
lineHeight: 1,
borderRadius: '4px',
}}
>
</button>
</div>
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* Invite by username/email */}
<div>
<div style={{
display: 'flex',
gap: '8px',
}}>
<input
type="text"
placeholder="Username or email..."
value={inviteInput}
onChange={(e) => setInviteInput(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter') handleInvite();
}}
onPointerDown={(e) => e.stopPropagation()}
onFocus={(e) => e.stopPropagation()}
style={{
flex: 1,
padding: '8px 12px',
fontSize: '12px',
fontFamily: 'inherit',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '6px',
background: 'var(--color-panel)',
color: 'var(--color-text)',
outline: 'none',
}}
/>
<button
onClick={handleInvite}
onPointerDown={(e) => e.stopPropagation()}
disabled={!inviteInput.trim() || inviteStatus === 'sending'}
style={{
padding: '8px 14px',
backgroundColor: inviteStatus === 'sent' ? '#10b981' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: !inviteInput.trim() || inviteStatus === 'sending' ? 'not-allowed' : 'pointer',
fontSize: '11px',
fontWeight: 500,
fontFamily: 'inherit',
opacity: !inviteInput.trim() ? 0.5 : 1,
transition: 'all 0.15s ease',
whiteSpace: 'nowrap',
}}
>
{inviteStatus === 'sending' ? '...' : inviteStatus === 'sent' ? '✓ Sent' : 'Invite'}
</button>
</div>
{inviteStatus === 'error' && (
<p style={{ fontSize: '11px', color: '#ef4444', marginTop: '4px' }}>
Failed to send invite. Please try again.
</p>
)}
</div>
{/* Divider with "or share link" */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
}}>
<div style={{ flex: 1, height: '1px', background: 'var(--color-panel-contrast)' }} />
<span style={{ fontSize: '11px', color: 'var(--color-text-3)', fontWeight: 500 }}>or share link</span>
<div style={{ flex: 1, height: '1px', background: 'var(--color-panel-contrast)' }} />
</div>
{/* Permission selector - pill style */}
<div style={{ display: 'flex', gap: '6px' }}>
{(['view', 'edit', 'admin'] as PermissionType[]).map((perm) => {
const isActive = permission === perm;
const { label, description } = PERMISSION_LABELS[perm];
return (
<button
key={perm}
onClick={() => setPermission(perm)}
onPointerDown={(e) => e.stopPropagation()}
title={description}
style={{
flex: 1,
padding: '8px 6px',
border: 'none',
background: isActive ? '#3b82f6' : 'var(--color-muted-2)',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '11px',
fontWeight: 500,
fontFamily: 'inherit',
color: isActive ? 'white' : 'var(--color-text)',
transition: 'all 0.15s ease',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2px',
}}
>
<span>{label}</span>
<span style={{
fontSize: '9px',
fontWeight: 400,
opacity: 0.8,
color: isActive ? 'rgba(255,255,255,0.9)' : 'var(--color-text-3)',
}}>
{perm === 'view' ? 'Read only' : perm === 'edit' ? 'Can edit' : 'Full access'}
</span>
</button>
);
})}
</div>
{/* QR Code and URL - larger and side by side */}
<div style={{
display: 'flex',
gap: '14px',
padding: '14px',
backgroundColor: 'var(--color-muted-2)',
borderRadius: '10px',
}}>
{/* QR Code - larger */}
<div style={{
padding: '10px',
backgroundColor: 'white',
borderRadius: '8px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<QRCodeSVG
value={getShareUrl()}
size={100}
level="M"
includeMargin={false}
/>
</div>
{/* URL and Copy - stacked */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: '10px' }}>
<div style={{
padding: '10px 12px',
backgroundColor: 'var(--color-panel)',
borderRadius: '6px',
border: '1px solid var(--color-panel-contrast)',
wordBreak: 'break-all',
fontSize: '11px',
fontFamily: 'monospace',
color: 'var(--color-text)',
lineHeight: 1.4,
}}>
{getShareUrl()}
</div>
<button
onClick={handleCopyUrl}
onPointerDown={(e) => e.stopPropagation()}
style={{
padding: '8px 12px',
backgroundColor: copied ? '#10b981' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '11px',
fontWeight: 500,
fontFamily: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
transition: 'all 0.15s ease',
}}
>
{copied ? (
<> Copied!</>
) : (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
Copy Link
</>
)}
</button>
</div>
</div>
{/* Advanced options (collapsible) */}
<div>
<button
onClick={() => setShowAdvanced(!showAdvanced)}
onPointerDown={(e) => e.stopPropagation()}
style={{
width: '100%',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '11px',
fontFamily: 'inherit',
color: 'var(--color-text-3)',
padding: '6px 0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
}}
>
<span style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '16px',
height: '16px',
borderRadius: '4px',
background: 'var(--color-muted-2)',
}}>
<svg
width="10"
height="10"
viewBox="0 0 16 16"
fill="currentColor"
style={{ transform: showAdvanced ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
>
<path d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
</svg>
</span>
More options
</button>
{showAdvanced && (
<div style={{ marginTop: '8px', display: 'flex', gap: '8px' }}>
{/* NFC Button */}
<button
onClick={handleNfcWrite}
onPointerDown={(e) => e.stopPropagation()}
disabled={nfcStatus === 'unsupported' || nfcStatus === 'writing'}
style={{
flex: 1,
padding: '10px',
fontFamily: 'inherit',
backgroundColor: nfcStatus === 'unsupported' ? 'var(--color-muted-2)' :
nfcStatus === 'success' ? '#d1fae5' :
nfcStatus === 'error' ? '#fee2e2' :
nfcStatus === 'writing' ? '#e0e7ff' : 'var(--color-panel)',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '6px',
cursor: nfcStatus === 'unsupported' || nfcStatus === 'writing' ? 'not-allowed' : 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
opacity: nfcStatus === 'unsupported' ? 0.5 : 1,
}}
>
<span style={{ fontSize: '16px' }}>
{nfcStatus === 'success' ? '✓' : nfcStatus === 'error' ? '!' : '📡'}
</span>
<span style={{ fontSize: '10px', color: 'var(--color-text)', fontWeight: 500 }}>
{nfcStatus === 'writing' ? 'Writing...' :
nfcStatus === 'success' ? 'Written!' :
nfcStatus === 'unsupported' ? 'NFC N/A' :
'NFC Tag'}
</span>
</button>
{/* Audio Button (coming soon) */}
<button
disabled
onPointerDown={(e) => e.stopPropagation()}
style={{
flex: 1,
padding: '10px',
fontFamily: 'inherit',
backgroundColor: 'var(--color-muted-2)',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '6px',
cursor: 'not-allowed',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
opacity: 0.5,
}}
>
<span style={{ fontSize: '16px' }}>🔊</span>
<span style={{ fontSize: '10px', color: 'var(--color-text)', fontWeight: 500 }}>
Audio (Soon)
</span>
</button>
</div>
)}
{nfcMessage && (
<p style={{
marginTop: '6px',
fontSize: '10px',
color: nfcStatus === 'error' ? '#ef4444' :
nfcStatus === 'success' ? '#10b981' : 'var(--color-text-3)',
textAlign: 'center',
}}>
{nfcMessage}
</p>
)}
</div>
</div>
</div>,
document.body
)}
</div>
);
}
// Full button version for other contexts (toolbar, etc.)
return (
<div ref={dropdownRef} style={{ position: 'relative' }}>
<button
onClick={() => setShowDropdown(!showDropdown)}
className={`share-board-button ${className}`}
title="Invite others to this board"
style={{
padding: "4px 8px",
borderRadius: "4px",
background: "#3b82f6",
color: "white",
border: "none",
cursor: "pointer",
fontWeight: 500,
transition: "background 0.2s ease",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
whiteSpace: "nowrap",
userSelect: "none",
display: "flex",
alignItems: "center",
gap: "4px",
height: "22px",
minHeight: "22px",
boxSizing: "border-box",
fontSize: "0.75rem",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#2563eb";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "#3b82f6";
}}
>
{/* User with plus icon (invite/add person) */}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="16" y1="11" x2="22" y2="11" />
</svg>
</button>
</div>
);
};
export default ShareBoardButton;

View File

@ -44,6 +44,10 @@ export interface StandardizedToolWrapperProps {
onMinimize?: () => void
/** Whether the tool is minimized */
isMinimized?: boolean
/** Callback when maximize button is clicked */
onMaximize?: () => void
/** Whether the tool is maximized (fullscreen) */
isMaximized?: boolean
/** Optional custom header content */
headerContent?: ReactNode
/** Editor instance for shape selection */
@ -76,6 +80,8 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
onClose,
onMinimize,
isMinimized = false,
onMaximize,
isMaximized = false,
headerContent,
editor,
shapeId,
@ -91,6 +97,22 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
const tagInputRef = useRef<HTMLInputElement>(null)
const isDarkMode = useIsDarkMode()
// Handle Esc key to exit maximize mode
useEffect(() => {
if (!isMaximized || !onMaximize) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
onMaximize()
}
}
window.addEventListener('keydown', handleKeyDown, true)
return () => window.removeEventListener('keydown', handleKeyDown, true)
}, [isMaximized, onMaximize])
// Dark mode aware colors
const colors = useMemo(() => isDarkMode ? {
contentBg: '#1a1a1a',
@ -166,7 +188,7 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
fontFamily: "Inter, sans-serif",
position: 'relative',
pointerEvents: 'auto',
transition: 'height 0.2s ease, box-shadow 0.2s ease',
transition: isPinnedToView ? 'box-shadow 0.2s ease' : 'height 0.2s ease, box-shadow 0.2s ease',
boxSizing: 'border-box',
}
@ -243,16 +265,25 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
color: isSelected ? 'white' : primaryColor,
}
const maximizeButtonStyle: React.CSSProperties = {
...buttonBaseStyle,
backgroundColor: isMaximized
? (isSelected ? 'rgba(255,255,255,0.4)' : primaryColor)
: (isSelected ? 'rgba(255,255,255,0.2)' : `${primaryColor}20`),
color: isMaximized
? (isSelected ? 'white' : 'white')
: (isSelected ? 'white' : primaryColor),
}
const contentStyle: React.CSSProperties = {
width: '100%',
height: isMinimized ? 0 : 'calc(100% - 40px)',
minHeight: 0, // Allow flex shrinking
overflow: 'auto',
position: 'relative',
pointerEvents: 'auto',
transition: 'height 0.2s ease',
display: 'flex',
flexDirection: 'column',
flex: 1,
flex: 1, // Take remaining space after header and tags
}
const tagsContainerStyle: React.CSSProperties = {
@ -488,6 +519,20 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
>
_
</button>
{onMaximize && (
<button
style={maximizeButtonStyle}
onClick={(e) => handleButtonClick(e, onMaximize)}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => handleButtonTouch(e, onMaximize)}
onTouchEnd={(e) => e.stopPropagation()}
title={isMaximized ? "Exit fullscreen (Esc)" : "Maximize"}
aria-label={isMaximized ? "Exit fullscreen" : "Maximize"}
>
{isMaximized ? '⊡' : '⤢'}
</button>
)}
<button
style={closeButtonStyle}
onClick={(e) => handleButtonClick(e, onClose)}

View File

@ -41,7 +41,7 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
const handleStarToggle = async () => {
if (!session.authed || !session.username || !slug) {
addNotification('Please log in to star boards', 'warning');
showPopupMessage('Please log in to star boards', 'error');
return;
}
@ -75,9 +75,75 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
}
};
// Don't show the button if user is not authenticated
if (!session.authed) {
return null;
// Detect if we're in share-panel (compact) vs toolbar (full button)
const isCompact = className.includes('share-panel-btn');
if (isCompact) {
// Icon-only version for the top-right share panel
return (
<div style={{ position: 'relative' }}>
<button
onClick={handleStarToggle}
disabled={isLoading}
className={`star-board-button ${className} ${isStarred ? 'starred' : ''}`}
title={!session.authed ? 'Log in to star boards' : isStarred ? 'Remove from starred boards' : 'Add to starred boards'}
style={{
background: 'none',
border: 'none',
padding: '6px',
cursor: 'pointer',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: isStarred ? '#f59e0b' : 'var(--color-text-1)',
opacity: isStarred ? 1 : 0.7,
transition: 'opacity 0.15s, background 0.15s, color 0.15s',
pointerEvents: 'all',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1';
e.currentTarget.style.background = 'var(--color-muted-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = isStarred ? '1' : '0.7';
e.currentTarget.style.background = 'none';
}}
>
{isLoading ? (
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" className="loading-spinner">
<path d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
{isStarred ? (
<path d="M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"/>
) : (
<path d="M2.866 14.85c-.078.444.36.791.746.593l4.39-2.256 4.389 2.256c.386.198.824-.149.746-.592l-.83-4.73 3.522-3.356c.33-.314.16-.888-.282-.95l-4.898-.696L8.465.792a.513.513 0 0 0-.927 0L5.354 5.12l-4.898.696c-.441.062-.612.636-.283.95l3.523 3.356-.83 4.73zm4.905-2.767-3.686 1.894.694-3.957a.565.565 0 0 0-.163-.505L1.71 6.745l4.052-.576a.525.525 0 0 0 .393-.288L8 2.223l1.847 3.658a.525.525 0 0 0 .393.288l4.052.575-2.906 2.77a.565.565 0 0 0-.163.506l.694 3.957-3.686-1.894a.503.503 0 0 0-.461 0z"/>
)}
</svg>
)}
</button>
{/* Custom popup notification */}
{showPopup && (
<div
className={`star-popup star-popup-${popupType}`}
style={{
position: 'absolute',
top: '40px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 100001,
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}
>
{popupMessage}
</div>
)}
</div>
);
}
return (
@ -86,14 +152,14 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
onClick={handleStarToggle}
disabled={isLoading}
className={`toolbar-btn star-board-button ${className} ${isStarred ? 'starred' : ''}`}
title={isStarred ? 'Remove from starred boards' : 'Add to starred boards'}
title={!session.authed ? 'Log in to star boards' : isStarred ? 'Remove from starred boards' : 'Add to starred boards'}
>
{isLoading ? (
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="loading-spinner">
<path d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<svg width="14" height="14" viewBox="0 0 16 16" fill={isStarred ? '#f59e0b' : 'currentColor'}>
{isStarred ? (
<path d="M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"/>
) : (

View File

@ -0,0 +1,200 @@
import { useState, useEffect, useCallback } from 'react'
import { useEditor, TLShapeId } from 'tldraw'
import { VisibilityChangeModal, shouldSkipVisibilityPrompt, setSkipVisibilityPrompt } from './VisibilityChangeModal'
import { updateItemVisibility, ItemVisibility } from '../shapes/GoogleItemShapeUtil'
import { findPrivateWorkspace, isShapeInPrivateWorkspace } from '../shapes/PrivateWorkspaceShapeUtil'
interface PendingChange {
shapeId: TLShapeId
currentVisibility: ItemVisibility
newVisibility: ItemVisibility
title: string
}
export function VisibilityChangeManager() {
const editor = useEditor()
const [pendingChange, setPendingChange] = useState<PendingChange | null>(null)
const [isDarkMode, setIsDarkMode] = useState(false)
// Detect dark mode
useEffect(() => {
const checkDarkMode = () => {
setIsDarkMode(document.documentElement.classList.contains('dark'))
}
checkDarkMode()
// Watch for class changes
const observer = new MutationObserver(checkDarkMode)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
})
return () => observer.disconnect()
}, [])
// Handle visibility change requests from GoogleItem shapes
useEffect(() => {
const handleVisibilityChangeRequest = (event: CustomEvent<{
shapeId: TLShapeId
currentVisibility: ItemVisibility
newVisibility: ItemVisibility
title: string
}>) => {
const { shapeId, currentVisibility, newVisibility, title } = event.detail
// Check if user has opted to skip prompts
if (shouldSkipVisibilityPrompt()) {
// Apply change immediately
updateItemVisibility(editor, shapeId, newVisibility)
return
}
// Show confirmation modal
setPendingChange({
shapeId,
currentVisibility,
newVisibility,
title,
})
}
window.addEventListener('request-visibility-change', handleVisibilityChangeRequest as EventListener)
return () => {
window.removeEventListener('request-visibility-change', handleVisibilityChangeRequest as EventListener)
}
}, [editor])
// Handle drag detection - check when items leave the Private Workspace
// Track GoogleItem positions to detect when they move outside workspace
useEffect(() => {
if (!editor) return
// Track which GoogleItems were inside workspace at start of drag
const wasInWorkspace = new Map<TLShapeId, boolean>()
let isDragging = false
// Record initial positions when pointer goes down
const handlePointerDown = () => {
const workspace = findPrivateWorkspace(editor)
if (!workspace) return
const selectedIds = editor.getSelectedShapeIds()
wasInWorkspace.clear()
for (const id of selectedIds) {
const shape = editor.getShape(id)
if (shape && shape.type === 'GoogleItem') {
const inWorkspace = isShapeInPrivateWorkspace(editor, id, workspace.id)
wasInWorkspace.set(id, inWorkspace)
}
}
isDragging = true
}
// Check for visibility changes when pointer goes up
const handlePointerUp = () => {
if (!isDragging || wasInWorkspace.size === 0) {
isDragging = false
return
}
const workspace = findPrivateWorkspace(editor)
if (!workspace) {
wasInWorkspace.clear()
isDragging = false
return
}
// Check each tracked shape
wasInWorkspace.forEach((wasIn, id) => {
const shape = editor.getShape(id)
if (!shape || shape.type !== 'GoogleItem') return
const isNowIn = isShapeInPrivateWorkspace(editor, id, workspace.id)
// If shape was in workspace and is now outside, trigger visibility change
if (wasIn && !isNowIn) {
const itemShape = shape as any // GoogleItem shape
if (itemShape.props.visibility === 'local') {
// Trigger visibility change request
window.dispatchEvent(new CustomEvent('request-visibility-change', {
detail: {
shapeId: id,
currentVisibility: 'local',
newVisibility: 'shared',
title: itemShape.props.title || 'Untitled',
}
}))
}
}
})
wasInWorkspace.clear()
isDragging = false
}
// Use DOM events for pointer tracking (more reliable with tldraw)
const canvas = document.querySelector('.tl-canvas')
if (canvas) {
canvas.addEventListener('pointerdown', handlePointerDown)
canvas.addEventListener('pointerup', handlePointerUp)
}
return () => {
if (canvas) {
canvas.removeEventListener('pointerdown', handlePointerDown)
canvas.removeEventListener('pointerup', handlePointerUp)
}
}
}, [editor])
// Handle modal confirmation
const handleConfirm = useCallback((dontAskAgain: boolean) => {
if (!pendingChange) return
// Update the shape visibility
updateItemVisibility(editor, pendingChange.shapeId, pendingChange.newVisibility)
// Save preference if requested
if (dontAskAgain) {
setSkipVisibilityPrompt(true)
}
setPendingChange(null)
}, [editor, pendingChange])
// Handle modal cancellation
const handleCancel = useCallback(() => {
if (!pendingChange) return
// If this was triggered by drag, move the shape back inside the workspace
const workspace = findPrivateWorkspace(editor)
if (workspace) {
const shape = editor.getShape(pendingChange.shapeId)
if (shape) {
// Move shape back inside workspace bounds
editor.updateShape({
id: pendingChange.shapeId,
type: shape.type,
x: workspace.x + 20,
y: workspace.y + 60,
})
}
}
setPendingChange(null)
}, [editor, pendingChange])
return (
<VisibilityChangeModal
isOpen={pendingChange !== null}
itemTitle={pendingChange?.title || ''}
currentVisibility={pendingChange?.currentVisibility || 'local'}
newVisibility={pendingChange?.newVisibility || 'shared'}
onConfirm={handleConfirm}
onCancel={handleCancel}
isDarkMode={isDarkMode}
/>
)
}

View File

@ -0,0 +1,360 @@
import { useState, useEffect, useRef } from 'react'
interface VisibilityChangeModalProps {
isOpen: boolean
itemTitle: string
currentVisibility: 'local' | 'shared'
newVisibility: 'local' | 'shared'
onConfirm: (dontAskAgain: boolean) => void
onCancel: () => void
isDarkMode: boolean
}
export function VisibilityChangeModal({
isOpen,
itemTitle,
currentVisibility,
newVisibility,
onConfirm,
onCancel,
isDarkMode,
}: VisibilityChangeModalProps) {
const [dontAskAgain, setDontAskAgain] = useState(false)
const modalRef = useRef<HTMLDivElement>(null)
// Handle escape key
useEffect(() => {
if (!isOpen) return
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel()
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen, onCancel])
// Dark mode colors
const colors = isDarkMode ? {
bg: '#1f2937',
cardBg: '#252525',
cardBorder: '#404040',
text: '#e4e4e7',
textMuted: '#a1a1aa',
textHeading: '#f4f4f5',
warningBg: 'rgba(251, 191, 36, 0.15)',
warningBorder: 'rgba(251, 191, 36, 0.3)',
warningText: '#fbbf24',
btnPrimaryBg: '#6366f1',
btnPrimaryText: '#ffffff',
btnSecondaryBg: '#333333',
btnSecondaryText: '#e4e4e4',
checkboxBg: '#333333',
checkboxBorder: '#555555',
localColor: '#6366f1',
sharedColor: '#22c55e',
} : {
bg: '#ffffff',
cardBg: '#f9fafb',
cardBorder: '#e5e7eb',
text: '#374151',
textMuted: '#6b7280',
textHeading: '#1f2937',
warningBg: 'rgba(251, 191, 36, 0.1)',
warningBorder: 'rgba(251, 191, 36, 0.3)',
warningText: '#92400e',
btnPrimaryBg: '#6366f1',
btnPrimaryText: '#ffffff',
btnSecondaryBg: '#f3f4f6',
btnSecondaryText: '#374151',
checkboxBg: '#ffffff',
checkboxBorder: '#d1d5db',
localColor: '#6366f1',
sharedColor: '#22c55e',
}
if (!isOpen) return null
const isSharing = currentVisibility === 'local' && newVisibility === 'shared'
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100010,
}}
onClick={onCancel}
>
<div
ref={modalRef}
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: colors.bg,
borderRadius: '12px',
width: '90%',
maxWidth: '420px',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: `1px solid ${colors.cardBorder}`,
overflow: 'hidden',
}}
>
{/* Header */}
<div
style={{
padding: '20px 24px 16px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<span style={{ fontSize: '24px' }}>
{isSharing ? '⚠️' : '🔒'}
</span>
<h2
style={{
fontSize: '18px',
fontWeight: '600',
color: colors.textHeading,
margin: 0,
}}
>
{isSharing ? 'Change Visibility?' : 'Make Private?'}
</h2>
</div>
{/* Content */}
<div style={{ padding: '0 24px 20px' }}>
<p style={{ fontSize: '14px', color: colors.text, margin: '0 0 16px 0', lineHeight: '1.5' }}>
{isSharing
? "You're about to make this item visible to others:"
: "You're about to make this item private:"}
</p>
{/* Item preview */}
<div
style={{
backgroundColor: colors.cardBg,
padding: '12px 14px',
borderRadius: '8px',
border: `1px solid ${colors.cardBorder}`,
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '10px',
}}
>
<span style={{ fontSize: '18px' }}>📄</span>
<span
style={{
fontSize: '14px',
fontWeight: '500',
color: colors.text,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{itemTitle}
</span>
</div>
{/* Current vs New state */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
fontSize: '13px',
marginBottom: '16px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 10px',
backgroundColor: isSharing ? `${colors.localColor}20` : `${colors.sharedColor}20`,
borderRadius: '6px',
color: isSharing ? colors.localColor : colors.sharedColor,
}}
>
<span>{isSharing ? '🔒' : '🌐'}</span>
<span>{isSharing ? 'Private' : 'Shared'}</span>
</div>
<span style={{ color: colors.textMuted }}></span>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 10px',
backgroundColor: isSharing ? `${colors.sharedColor}20` : `${colors.localColor}20`,
borderRadius: '6px',
color: isSharing ? colors.sharedColor : colors.localColor,
}}
>
<span>{isSharing ? '🌐' : '🔒'}</span>
<span>{isSharing ? 'Shared' : 'Private'}</span>
</div>
</div>
{/* Warning for sharing */}
{isSharing && (
<div
style={{
backgroundColor: colors.warningBg,
border: `1px solid ${colors.warningBorder}`,
borderRadius: '8px',
padding: '12px 14px',
marginBottom: '16px',
}}
>
<p
style={{
fontSize: '12px',
color: colors.warningText,
margin: 0,
lineHeight: '1.5',
}}
>
<strong>Note:</strong> Shared items will be visible to all collaborators
on this board and may be uploaded to cloud storage.
</p>
</div>
)}
{/* Info for making private */}
{!isSharing && (
<div
style={{
backgroundColor: isDarkMode ? 'rgba(99, 102, 241, 0.15)' : 'rgba(99, 102, 241, 0.1)',
border: `1px solid ${isDarkMode ? 'rgba(99, 102, 241, 0.3)' : 'rgba(99, 102, 241, 0.3)'}`,
borderRadius: '8px',
padding: '12px 14px',
marginBottom: '16px',
}}
>
<p
style={{
fontSize: '12px',
color: isDarkMode ? '#a5b4fc' : '#4f46e5',
margin: 0,
lineHeight: '1.5',
}}
>
<strong>Note:</strong> Private items are only visible to you and remain
encrypted in your browser. Other collaborators won't be able to see this item.
</p>
</div>
)}
{/* Don't ask again checkbox */}
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: 'pointer',
fontSize: '13px',
color: colors.textMuted,
}}
>
<input
type="checkbox"
checked={dontAskAgain}
onChange={(e) => setDontAskAgain(e.target.checked)}
style={{
width: '16px',
height: '16px',
cursor: 'pointer',
}}
/>
Don't ask again for this session
</label>
</div>
{/* Actions */}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '10px',
padding: '16px 24px',
borderTop: `1px solid ${colors.cardBorder}`,
backgroundColor: colors.cardBg,
}}
>
<button
onClick={onCancel}
style={{
padding: '10px 18px',
borderRadius: '8px',
border: `1px solid ${colors.cardBorder}`,
backgroundColor: colors.btnSecondaryBg,
color: colors.btnSecondaryText,
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={() => onConfirm(dontAskAgain)}
style={{
padding: '10px 18px',
borderRadius: '8px',
border: 'none',
backgroundColor: isSharing ? colors.sharedColor : colors.localColor,
color: '#ffffff',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<span>{isSharing ? '🌐' : '🔒'}</span>
{isSharing ? 'Make Shared' : 'Make Private'}
</button>
</div>
</div>
</div>
)
}
// Session storage key for "don't ask again" preference
const DONT_ASK_KEY = 'visibility-change-dont-ask'
export function shouldSkipVisibilityPrompt(): boolean {
try {
return sessionStorage.getItem(DONT_ASK_KEY) === 'true'
} catch {
return false
}
}
export function setSkipVisibilityPrompt(skip: boolean): void {
try {
if (skip) {
sessionStorage.setItem(DONT_ASK_KEY, 'true')
} else {
sessionStorage.removeItem(DONT_ASK_KEY)
}
} catch {
// Ignore storage errors
}
}

View File

@ -0,0 +1,420 @@
// Year View Panel - KalNext-style 12-month yearly overview
// Shows all months in a 4x3 grid with event density indicators
import React, { useState, useMemo } from "react"
import { useCalendarEvents, type DecryptedCalendarEvent } from "@/hooks/useCalendarEvents"
interface YearViewPanelProps {
onClose?: () => void
onMonthSelect?: (year: number, month: number) => void
shapeMode?: boolean
initialYear?: number
}
// Helper functions
const getDaysInMonth = (year: number, month: number) => {
return new Date(year, month + 1, 0).getDate()
}
const getFirstDayOfMonth = (year: number, month: number) => {
const day = new Date(year, month, 1).getDay()
return day === 0 ? 6 : day - 1 // Monday-first
}
const isSameDay = (date1: Date, date2: Date) => {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
)
}
const MONTH_NAMES = [
"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December"
]
const SHORT_MONTH_NAMES = [
"Jan", "Feb", "Mar", "Apr",
"May", "Jun", "Jul", "Aug",
"Sep", "Oct", "Nov", "Dec"
]
export const YearViewPanel: React.FC<YearViewPanelProps> = ({
onClose: _onClose,
onMonthSelect,
shapeMode: _shapeMode = false,
initialYear,
}) => {
const [currentYear, setCurrentYear] = useState(initialYear || new Date().getFullYear())
// Detect dark mode
const isDarkMode =
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark")
// Fetch all events for the current year
const yearStart = new Date(currentYear, 0, 1)
const yearEnd = new Date(currentYear, 11, 31, 23, 59, 59)
const { events, loading, getEventsForDate } = useCalendarEvents({
startDate: yearStart,
endDate: yearEnd,
})
// Colors
const colors = isDarkMode
? {
bg: "#1f2937",
text: "#e4e4e7",
textMuted: "#a1a1aa",
border: "#374151",
monthBg: "#252525",
todayBg: "#22c55e30",
todayBorder: "#22c55e",
eventDot1: "#3b82f620", // 1 event
eventDot2: "#3b82f640", // 2 events
eventDot3: "#3b82f680", // 3+ events
eventDotMax: "#3b82f6", // 5+ events
headerBg: "#22c55e",
}
: {
bg: "#f9fafb",
text: "#1f2937",
textMuted: "#6b7280",
border: "#e5e7eb",
monthBg: "#ffffff",
todayBg: "#22c55e20",
todayBorder: "#22c55e",
eventDot1: "#3b82f620",
eventDot2: "#3b82f640",
eventDot3: "#3b82f680",
eventDotMax: "#3b82f6",
headerBg: "#22c55e",
}
// Get event count for a specific date
const getEventCount = (date: Date) => {
return getEventsForDate(date).length
}
// Get background color based on event density
const getEventDensityColor = (count: number) => {
if (count === 0) return "transparent"
if (count === 1) return colors.eventDot1
if (count === 2) return colors.eventDot2
if (count <= 4) return colors.eventDot3
return colors.eventDotMax
}
// Navigation
const goToPrevYear = () => setCurrentYear((y) => y - 1)
const goToNextYear = () => setCurrentYear((y) => y + 1)
const goToCurrentYear = () => setCurrentYear(new Date().getFullYear())
const today = new Date()
// Generate mini calendar for a month
const renderMiniMonth = (month: number) => {
const daysInMonth = getDaysInMonth(currentYear, month)
const firstDay = getFirstDayOfMonth(currentYear, month)
const days: { day: number | null; date: Date | null }[] = []
// Leading empty cells
for (let i = 0; i < firstDay; i++) {
days.push({ day: null, date: null })
}
// Days of month
for (let i = 1; i <= daysInMonth; i++) {
days.push({ day: i, date: new Date(currentYear, month, i) })
}
// Trailing empty cells to complete grid (6 rows max)
while (days.length < 42) {
days.push({ day: null, date: null })
}
return (
<div
key={month}
style={{
backgroundColor: colors.monthBg,
borderRadius: "8px",
padding: "8px",
border: `1px solid ${colors.border}`,
cursor: onMonthSelect ? "pointer" : "default",
}}
onClick={() => onMonthSelect?.(currentYear, month)}
onPointerDown={(e) => e.stopPropagation()}
>
{/* Month name */}
<div
style={{
fontSize: "11px",
fontWeight: "600",
color: colors.text,
marginBottom: "6px",
textAlign: "center",
}}
>
{SHORT_MONTH_NAMES[month]}
</div>
{/* Day headers */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "1px",
marginBottom: "2px",
}}
>
{["M", "T", "W", "T", "F", "S", "S"].map((day, i) => (
<div
key={i}
style={{
textAlign: "center",
fontSize: "7px",
fontWeight: "500",
color: colors.textMuted,
}}
>
{day}
</div>
))}
</div>
{/* Days grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "1px",
}}
>
{days.slice(0, 42).map(({ day, date }, i) => {
const isToday = date && isSameDay(date, today)
const eventCount = date ? getEventCount(date) : 0
const densityColor = getEventDensityColor(eventCount)
return (
<div
key={i}
style={{
textAlign: "center",
fontSize: "8px",
padding: "2px 0",
borderRadius: "2px",
backgroundColor: isToday ? colors.todayBg : densityColor,
border: isToday ? `1px solid ${colors.todayBorder}` : "1px solid transparent",
color: day ? colors.text : "transparent",
fontWeight: isToday ? "700" : "400",
minHeight: "14px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{day}
</div>
)
})}
</div>
</div>
)
}
return (
<div
style={{
width: "100%",
height: "100%",
backgroundColor: colors.bg,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{/* Header with year navigation */}
<div
style={{
padding: "12px 16px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: `1px solid ${colors.border}`,
backgroundColor: colors.headerBg,
}}
>
<button
onClick={goToPrevYear}
onPointerDown={(e) => e.stopPropagation()}
style={{
background: "rgba(255, 255, 255, 0.2)",
border: "none",
color: "#fff",
width: "32px",
height: "32px",
borderRadius: "6px",
cursor: "pointer",
fontSize: "14px",
fontWeight: "600",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
&lt;
</button>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<span
style={{
fontSize: "18px",
fontWeight: "700",
color: "#fff",
}}
>
{currentYear}
</span>
{currentYear !== new Date().getFullYear() && (
<button
onClick={goToCurrentYear}
onPointerDown={(e) => e.stopPropagation()}
style={{
background: "rgba(255, 255, 255, 0.2)",
border: "none",
color: "#fff",
padding: "4px 10px",
borderRadius: "4px",
cursor: "pointer",
fontSize: "11px",
fontWeight: "500",
}}
>
Today
</button>
)}
</div>
<button
onClick={goToNextYear}
onPointerDown={(e) => e.stopPropagation()}
style={{
background: "rgba(255, 255, 255, 0.2)",
border: "none",
color: "#fff",
width: "32px",
height: "32px",
borderRadius: "6px",
cursor: "pointer",
fontSize: "14px",
fontWeight: "600",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
&gt;
</button>
</div>
{/* 12-month grid (4x3 layout) */}
<div
style={{
flex: 1,
padding: "12px",
overflow: "auto",
}}
>
{loading ? (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: colors.textMuted,
fontSize: "13px",
}}
>
Loading calendar data...
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gridTemplateRows: "repeat(3, 1fr)",
gap: "10px",
height: "100%",
}}
>
{Array.from({ length: 12 }, (_, month) => renderMiniMonth(month))}
</div>
)}
</div>
{/* Legend */}
<div
style={{
padding: "10px 16px",
borderTop: `1px solid ${colors.border}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "16px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<div
style={{
width: "12px",
height: "12px",
borderRadius: "2px",
backgroundColor: colors.todayBg,
border: `1px solid ${colors.todayBorder}`,
}}
/>
<span style={{ fontSize: "10px", color: colors.textMuted }}>Today</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<div
style={{
width: "12px",
height: "12px",
borderRadius: "2px",
backgroundColor: colors.eventDot1,
}}
/>
<span style={{ fontSize: "10px", color: colors.textMuted }}>1 event</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<div
style={{
width: "12px",
height: "12px",
borderRadius: "2px",
backgroundColor: colors.eventDot3,
}}
/>
<span style={{ fontSize: "10px", color: colors.textMuted }}>3+ events</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<div
style={{
width: "12px",
height: "12px",
borderRadius: "2px",
backgroundColor: colors.eventDotMax,
}}
/>
<span style={{ fontSize: "10px", color: colors.textMuted }}>5+ events</span>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,199 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import CryptID from './CryptID';
import '../../css/anonymous-banner.css';
interface AnonymousViewerBannerProps {
/** Callback when user successfully signs up/logs in */
onAuthenticated?: () => void;
/** Whether the banner was triggered by an edit attempt */
triggeredByEdit?: boolean;
}
/**
* Banner shown to anonymous (unauthenticated) users viewing a board.
* Explains CryptID and provides a smooth sign-up flow.
*
* Note: This component should only be rendered when user is NOT authenticated.
* The parent component (Board.tsx) handles the auth check via:
* {(!session.authed || showEditPrompt) && <AnonymousViewerBanner ... />}
*/
const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
onAuthenticated,
triggeredByEdit = false
}) => {
const [isDismissed, setIsDismissed] = useState(false);
const [showSignUp, setShowSignUp] = useState(false);
// Note: We intentionally do NOT persist banner dismissal across page loads.
// The banner should appear on each new page load for anonymous users
// to remind them about CryptID. Only dismiss within the current component lifecycle.
//
// Previous implementation used sessionStorage to remember dismissal, but this caused
// issues where users who dismissed once would never see it again until they closed
// their browser entirely - even if they logged out or their session expired.
//
// If triggeredByEdit is true, always show regardless of dismiss state.
// If dismissed and not triggered by edit, don't show
if (isDismissed && !triggeredByEdit) {
return null;
}
const handleDismiss = () => {
// Just set local state - don't persist to sessionStorage
// This allows the banner to show again on page refresh
setIsDismissed(true);
};
const handleSignUpClick = () => {
setShowSignUp(true);
};
const handleSignUpSuccess = () => {
setShowSignUp(false);
// Dismiss the banner when user signs in successfully
// No need to persist - the parent condition (!session.authed) will hide us
setIsDismissed(true);
if (onAuthenticated) {
onAuthenticated();
}
};
const handleSignUpCancel = () => {
setShowSignUp(false);
};
return (
<div className={`anonymous-viewer-banner ${triggeredByEdit ? 'edit-triggered' : ''}`}>
{/* Dismiss button in top-right corner */}
{!triggeredByEdit && (
<button
className="banner-dismiss-btn"
onClick={handleDismiss}
title="Dismiss"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" fill="currentColor"/>
</svg>
</button>
)}
<div className="banner-content">
<div className="banner-header">
<div className="banner-icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z" fill="currentColor"/>
</svg>
</div>
<div className="banner-text">
{triggeredByEdit ? (
<p className="banner-headline">
<strong>Sign in to edit</strong>
</p>
) : (
<p className="banner-headline">
<strong>Viewing anonymously</strong>
</p>
)}
<p className="banner-summary">
Sign in with CryptID to edit
</p>
</div>
</div>
{/* Action button */}
<div className="banner-actions">
<button
className="banner-signup-btn"
onClick={handleSignUpClick}
>
Sign in
</button>
</div>
</div>
{triggeredByEdit && (
<div className="banner-edit-notice">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" fill="currentColor"/>
</svg>
<span>Read-only for anonymous viewers</span>
</div>
)}
{/* CryptID Sign In Modal - same as CryptIDDropdown */}
{showSignUp && createPortal(
<div
className="cryptid-modal-overlay"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 999999,
}}
onClick={(e) => {
if (e.target === e.currentTarget) {
handleSignUpCancel();
}
}}
>
<div
className="cryptid-modal"
style={{
backgroundColor: 'var(--color-panel, #ffffff)',
borderRadius: '16px',
padding: '0',
maxWidth: '580px',
width: '95vw',
maxHeight: '90vh',
boxShadow: '0 25px 80px rgba(0, 0, 0, 0.4)',
overflow: 'auto',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Close button */}
<button
onClick={handleSignUpCancel}
style={{
position: 'absolute',
top: '12px',
right: '12px',
background: 'var(--color-muted-2, #f3f4f6)',
border: 'none',
borderRadius: '50%',
width: '28px',
height: '28px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-2, #6b7280)',
fontSize: '16px',
zIndex: 1,
}}
>
×
</button>
<CryptID
onSuccess={handleSignUpSuccess}
onCancel={handleSignUpCancel}
/>
</div>
</div>,
document.body
)}
</div>
);
};
export default AnonymousViewerBanner;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -32,7 +32,6 @@ export const Profile: React.FC<ProfileProps> = ({ onLogout, onOpenVaultBrowser }
obsidianVaultName: undefined
});
setIsEditingVault(false);
console.log('🔧 Vault disconnected from profile');
};
const handleChangeVault = () => {

View File

@ -0,0 +1,631 @@
/**
* VersionHistoryPanel Component
*
* Displays version history timeline with diff visualization.
* - Shows timeline of changes
* - Highlights additions (green) and deletions (red)
* - Allows reverting to previous versions
*/
import React, { useState, useEffect, useCallback } from 'react';
import { WORKER_URL } from '../../constants/workerUrl';
// =============================================================================
// Types
// =============================================================================
interface HistoryEntry {
hash: string;
timestamp: string | null;
message: string | null;
actor: string;
}
interface SnapshotDiff {
added: Record<string, any>;
removed: Record<string, any>;
modified: Record<string, { before: any; after: any }>;
}
interface VersionHistoryPanelProps {
roomId: string;
onClose: () => void;
onRevert?: (hash: string) => void;
isDarkMode?: boolean;
}
// =============================================================================
// Helper Functions
// =============================================================================
function formatTimestamp(timestamp: string | null): string {
if (!timestamp) return 'Unknown time';
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
// Less than 1 minute ago
if (diff < 60000) return 'Just now';
// Less than 1 hour ago
if (diff < 3600000) {
const mins = Math.floor(diff / 60000);
return `${mins} minute${mins !== 1 ? 's' : ''} ago`;
}
// Less than 24 hours ago
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000);
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
}
// Less than 7 days ago
if (diff < 604800000) {
const days = Math.floor(diff / 86400000);
return `${days} day${days !== 1 ? 's' : ''} ago`;
}
// Older - show full date
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
hour: '2-digit',
minute: '2-digit',
});
}
function getShapeLabel(record: any): string {
if (record?.typeName === 'shape') {
const type = record.type || 'shape';
const name = record.props?.name || record.props?.text?.slice?.(0, 20) || '';
if (name) return `${type}: "${name}"`;
return type;
}
if (record?.typeName === 'page') {
return `Page: ${record.name || 'Untitled'}`;
}
return record?.typeName || 'Record';
}
// =============================================================================
// Component
// =============================================================================
export function VersionHistoryPanel({
roomId,
onClose,
onRevert,
isDarkMode = false,
}: VersionHistoryPanelProps) {
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedEntry, setSelectedEntry] = useState<HistoryEntry | null>(null);
const [diff, setDiff] = useState<SnapshotDiff | null>(null);
const [isLoadingDiff, setIsLoadingDiff] = useState(false);
const [isReverting, setIsReverting] = useState(false);
const [showConfirmRevert, setShowConfirmRevert] = useState(false);
// Fetch history on mount
useEffect(() => {
fetchHistory();
}, [roomId]);
const fetchHistory = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`${WORKER_URL}/room/${roomId}/history`);
if (!response.ok) throw new Error('Failed to fetch history');
const data = await response.json() as { history?: HistoryEntry[] };
setHistory(data.history || []);
} catch (err) {
setError((err as Error).message);
} finally {
setIsLoading(false);
}
};
const fetchDiff = async (entry: HistoryEntry, prevEntry: HistoryEntry | null) => {
setIsLoadingDiff(true);
try {
const response = await fetch(`${WORKER_URL}/room/${roomId}/diff`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fromHash: prevEntry?.hash || null,
toHash: entry.hash,
}),
});
if (!response.ok) throw new Error('Failed to fetch diff');
const data = await response.json() as { diff?: SnapshotDiff };
setDiff(data.diff || null);
} catch (err) {
console.error('Failed to fetch diff:', err);
setDiff(null);
} finally {
setIsLoadingDiff(false);
}
};
const handleEntryClick = (entry: HistoryEntry, index: number) => {
setSelectedEntry(entry);
const prevEntry = index < history.length - 1 ? history[index + 1] : null;
fetchDiff(entry, prevEntry);
};
const handleRevert = async () => {
if (!selectedEntry) return;
setIsReverting(true);
try {
const response = await fetch(`${WORKER_URL}/room/${roomId}/revert`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash: selectedEntry.hash }),
});
if (!response.ok) throw new Error('Failed to revert');
// Notify parent
onRevert?.(selectedEntry.hash);
setShowConfirmRevert(false);
// Refresh history
await fetchHistory();
} catch (err) {
setError((err as Error).message);
} finally {
setIsReverting(false);
}
};
// Styles
const theme = {
bg: isDarkMode ? '#1e1e1e' : '#ffffff',
bgSecondary: isDarkMode ? '#2d2d2d' : '#f5f5f5',
text: isDarkMode ? '#e0e0e0' : '#333333',
textMuted: isDarkMode ? '#888888' : '#666666',
border: isDarkMode ? '#404040' : '#e0e0e0',
accent: '#8b5cf6',
green: isDarkMode ? '#4ade80' : '#16a34a',
red: isDarkMode ? '#f87171' : '#dc2626',
greenBg: isDarkMode ? 'rgba(74, 222, 128, 0.15)' : 'rgba(22, 163, 74, 0.1)',
redBg: isDarkMode ? 'rgba(248, 113, 113, 0.15)' : 'rgba(220, 38, 38, 0.1)',
};
return (
<div
style={{
position: 'fixed',
top: 0,
right: 0,
width: '400px',
height: '100vh',
backgroundColor: theme.bg,
borderLeft: `1px solid ${theme.border}`,
display: 'flex',
flexDirection: 'column',
zIndex: 2000,
boxShadow: '-4px 0 24px rgba(0, 0, 0, 0.15)',
}}
>
{/* Header */}
<div
style={{
padding: '16px 20px',
borderBottom: `1px solid ${theme.border}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke={theme.accent}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span style={{ fontWeight: 600, color: theme.text, fontSize: '16px' }}>
Version History
</span>
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px',
color: theme.textMuted,
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
{isLoading ? (
<div
style={{
padding: '40px 20px',
textAlign: 'center',
color: theme.textMuted,
}}
>
Loading history...
</div>
) : error ? (
<div
style={{
padding: '20px',
textAlign: 'center',
color: theme.red,
}}
>
{error}
<button
onClick={fetchHistory}
style={{
display: 'block',
margin: '10px auto 0',
padding: '8px 16px',
backgroundColor: theme.accent,
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
Retry
</button>
</div>
) : history.length === 0 ? (
<div
style={{
padding: '40px 20px',
textAlign: 'center',
color: theme.textMuted,
}}
>
No version history available
</div>
) : (
<>
{/* Timeline */}
<div style={{ flex: '0 0 auto', maxHeight: '40%', overflow: 'auto', padding: '12px 0' }}>
{history.map((entry, index) => (
<div
key={entry.hash}
onClick={() => handleEntryClick(entry, index)}
style={{
padding: '12px 20px',
cursor: 'pointer',
borderLeft: `3px solid ${
selectedEntry?.hash === entry.hash ? theme.accent : 'transparent'
}`,
backgroundColor:
selectedEntry?.hash === entry.hash ? theme.bgSecondary : 'transparent',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
if (selectedEntry?.hash !== entry.hash) {
e.currentTarget.style.backgroundColor = theme.bgSecondary;
}
}}
onMouseLeave={(e) => {
if (selectedEntry?.hash !== entry.hash) {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
<div
style={{
fontSize: '13px',
fontWeight: 500,
color: theme.text,
marginBottom: '4px',
}}
>
{entry.message || `Change ${entry.hash.slice(0, 8)}`}
</div>
<div
style={{
fontSize: '11px',
color: theme.textMuted,
}}
>
{formatTimestamp(entry.timestamp)}
</div>
</div>
))}
</div>
{/* Diff View */}
{selectedEntry && (
<div
style={{
flex: 1,
borderTop: `1px solid ${theme.border}`,
overflow: 'auto',
padding: '16px 20px',
}}
>
<div
style={{
fontSize: '12px',
fontWeight: 600,
color: theme.textMuted,
marginBottom: '12px',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}
>
Changes in this version
</div>
{isLoadingDiff ? (
<div style={{ color: theme.textMuted, fontSize: '13px' }}>
Loading diff...
</div>
) : diff ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{/* Added */}
{Object.entries(diff.added).length > 0 && (
<div>
<div
style={{
fontSize: '11px',
fontWeight: 600,
color: theme.green,
marginBottom: '6px',
}}
>
+ Added ({Object.entries(diff.added).filter(([id]) => id.startsWith('shape:')).length} shapes)
</div>
{Object.entries(diff.added)
.filter(([id]) => id.startsWith('shape:'))
.slice(0, 10)
.map(([id, record]) => (
<div
key={id}
style={{
padding: '8px 12px',
backgroundColor: theme.greenBg,
borderLeft: `3px solid ${theme.green}`,
borderRadius: '4px',
marginBottom: '4px',
fontSize: '12px',
color: theme.text,
}}
>
{getShapeLabel(record)}
</div>
))}
{Object.entries(diff.added).filter(([id]) => id.startsWith('shape:')).length > 10 && (
<div style={{ fontSize: '11px', color: theme.textMuted, marginLeft: '12px' }}>
...and {Object.entries(diff.added).filter(([id]) => id.startsWith('shape:')).length - 10} more
</div>
)}
</div>
)}
{/* Removed */}
{Object.entries(diff.removed).length > 0 && (
<div>
<div
style={{
fontSize: '11px',
fontWeight: 600,
color: theme.red,
marginBottom: '6px',
}}
>
- Removed ({Object.entries(diff.removed).filter(([id]) => id.startsWith('shape:')).length} shapes)
</div>
{Object.entries(diff.removed)
.filter(([id]) => id.startsWith('shape:'))
.slice(0, 10)
.map(([id, record]) => (
<div
key={id}
style={{
padding: '8px 12px',
backgroundColor: theme.redBg,
borderLeft: `3px solid ${theme.red}`,
borderRadius: '4px',
marginBottom: '4px',
fontSize: '12px',
color: theme.text,
}}
>
{getShapeLabel(record)}
</div>
))}
{Object.entries(diff.removed).filter(([id]) => id.startsWith('shape:')).length > 10 && (
<div style={{ fontSize: '11px', color: theme.textMuted, marginLeft: '12px' }}>
...and {Object.entries(diff.removed).filter(([id]) => id.startsWith('shape:')).length - 10} more
</div>
)}
</div>
)}
{/* Modified */}
{Object.entries(diff.modified).length > 0 && (
<div>
<div
style={{
fontSize: '11px',
fontWeight: 600,
color: theme.accent,
marginBottom: '6px',
}}
>
~ Modified ({Object.entries(diff.modified).filter(([id]) => id.startsWith('shape:')).length} shapes)
</div>
{Object.entries(diff.modified)
.filter(([id]) => id.startsWith('shape:'))
.slice(0, 5)
.map(([id, { after }]) => (
<div
key={id}
style={{
padding: '8px 12px',
backgroundColor: theme.bgSecondary,
borderLeft: `3px solid ${theme.accent}`,
borderRadius: '4px',
marginBottom: '4px',
fontSize: '12px',
color: theme.text,
}}
>
{getShapeLabel(after)}
</div>
))}
{Object.entries(diff.modified).filter(([id]) => id.startsWith('shape:')).length > 5 && (
<div style={{ fontSize: '11px', color: theme.textMuted, marginLeft: '12px' }}>
...and {Object.entries(diff.modified).filter(([id]) => id.startsWith('shape:')).length - 5} more
</div>
)}
</div>
)}
{/* No visible changes */}
{Object.entries(diff.added).filter(([id]) => id.startsWith('shape:')).length === 0 &&
Object.entries(diff.removed).filter(([id]) => id.startsWith('shape:')).length === 0 &&
Object.entries(diff.modified).filter(([id]) => id.startsWith('shape:')).length === 0 && (
<div style={{ color: theme.textMuted, fontSize: '13px' }}>
No visible shape changes in this version
</div>
)}
</div>
) : (
<div style={{ color: theme.textMuted, fontSize: '13px' }}>
Select a version to see changes
</div>
)}
{/* Revert Button */}
{selectedEntry && history.indexOf(selectedEntry) !== 0 && (
<div style={{ marginTop: '20px' }}>
{showConfirmRevert ? (
<div
style={{
padding: '12px',
backgroundColor: theme.redBg,
borderRadius: '8px',
border: `1px solid ${theme.red}`,
}}
>
<div style={{ fontSize: '13px', color: theme.text, marginBottom: '12px' }}>
Are you sure you want to revert to this version? This will restore the board to this point in time.
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handleRevert}
disabled={isReverting}
style={{
flex: 1,
padding: '8px 16px',
backgroundColor: theme.red,
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: isReverting ? 'not-allowed' : 'pointer',
opacity: isReverting ? 0.7 : 1,
fontWeight: 500,
}}
>
{isReverting ? 'Reverting...' : 'Yes, Revert'}
</button>
<button
onClick={() => setShowConfirmRevert(false)}
style={{
flex: 1,
padding: '8px 16px',
backgroundColor: theme.bgSecondary,
color: theme.text,
border: `1px solid ${theme.border}`,
borderRadius: '6px',
cursor: 'pointer',
fontWeight: 500,
}}
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => setShowConfirmRevert(true)}
style={{
width: '100%',
padding: '10px 16px',
backgroundColor: 'transparent',
color: theme.accent,
border: `1px solid ${theme.accent}`,
borderRadius: '8px',
cursor: 'pointer',
fontWeight: 500,
fontSize: '13px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = theme.accent;
e.currentTarget.style.color = 'white';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = theme.accent;
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
Revert to this version
</button>
)}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
);
}
export default VersionHistoryPanel;

View File

@ -0,0 +1,3 @@
export { VersionHistoryPanel } from './VersionHistoryPanel';
export { useVersionHistory } from './useVersionHistory';
export type { HistoryEntry, SnapshotDiff, UseVersionHistoryReturn } from './useVersionHistory';

View File

@ -0,0 +1,103 @@
/**
* useVersionHistory Hook
*
* Provides version history functionality for a board.
*/
import { useState, useCallback } from 'react';
import { WORKER_URL } from '../../constants/workerUrl';
export interface HistoryEntry {
hash: string;
timestamp: string | null;
message: string | null;
actor: string;
}
export interface SnapshotDiff {
added: Record<string, any>;
removed: Record<string, any>;
modified: Record<string, { before: any; after: any }>;
}
export interface UseVersionHistoryReturn {
history: HistoryEntry[];
isLoading: boolean;
error: string | null;
fetchHistory: () => Promise<void>;
fetchDiff: (fromHash: string | null, toHash: string | null) => Promise<SnapshotDiff | null>;
revert: (hash: string) => Promise<boolean>;
}
export function useVersionHistory(roomId: string): UseVersionHistoryReturn {
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchHistory = useCallback(async () => {
if (!roomId) return;
setIsLoading(true);
setError(null);
try {
const response = await fetch(`${WORKER_URL}/room/${roomId}/history`);
if (!response.ok) throw new Error('Failed to fetch history');
const data = await response.json() as { history?: HistoryEntry[] };
setHistory(data.history || []);
} catch (err) {
setError((err as Error).message);
} finally {
setIsLoading(false);
}
}, [roomId]);
const fetchDiff = useCallback(
async (fromHash: string | null, toHash: string | null): Promise<SnapshotDiff | null> => {
if (!roomId) return null;
try {
const response = await fetch(`${WORKER_URL}/room/${roomId}/diff`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fromHash, toHash }),
});
if (!response.ok) throw new Error('Failed to fetch diff');
const data = await response.json() as { diff?: SnapshotDiff };
return data.diff || null;
} catch (err) {
console.error('Failed to fetch diff:', err);
return null;
}
},
[roomId]
);
const revert = useCallback(
async (hash: string): Promise<boolean> => {
if (!roomId) return false;
try {
const response = await fetch(`${WORKER_URL}/room/${roomId}/revert`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash }),
});
if (!response.ok) throw new Error('Failed to revert');
return true;
} catch (err) {
setError((err as Error).message);
return false;
}
},
[roomId]
);
return {
history,
isLoading,
error,
fetchHistory,
fetchDiff,
revert,
};
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,990 @@
/**
* NetworkGraphMinimap Component
*
* A force-directed social graph visualization positioned above the minimap.
* Shows:
* - User's full network with trust-level coloring
* - Room participants in their presence colors
* - Connections as edges between nodes
* - Mutual connections as thicker lines
*
* Features:
* - Three display modes: minimized (icon), normal (small window), maximized (modal)
* - Click node to view profile / connect
* - Click edge to edit metadata
* - Hover for tooltips
* - Stable simulation that doesn't constantly reinitialize
*
* Positioned in bottom-left, above the tldraw minimap.
*/
import React, { useEffect, useRef, useState, useCallback, useMemo, Suspense, lazy } from 'react';
import * as d3 from 'd3';
import { type GraphNode, type GraphEdge, type TrustLevel } from '../../lib/networking';
import { UserSearchModal } from './UserSearchModal';
// Lazy load the 3D component to avoid loading Three.js unless needed
const NetworkGraph3D = lazy(() => import('./NetworkGraph3D'));
// =============================================================================
// Types
// =============================================================================
type DisplayMode = 'minimized' | 'normal' | 'maximized';
interface NetworkGraphMinimapProps {
nodes: GraphNode[];
edges: GraphEdge[];
myConnections: string[];
currentUserId?: string;
onConnect: (userId: string, trustLevel?: TrustLevel) => Promise<void>;
onDisconnect?: (connectionId: string) => Promise<void>;
onNodeClick?: (node: GraphNode) => void;
onGoToUser?: (node: GraphNode) => void;
onFollowUser?: (node: GraphNode) => void;
onOpenProfile?: (node: GraphNode) => void;
onEdgeClick?: (edge: GraphEdge) => void;
onExpandClick?: () => void;
width?: number;
height?: number;
isCollapsed?: boolean;
onToggleCollapse?: () => void;
isDarkMode?: boolean;
}
interface SimulationNode extends d3.SimulationNodeDatum, GraphNode {}
interface SimulationLink extends d3.SimulationLinkDatum<SimulationNode> {
id: string;
isMutual: boolean;
}
// =============================================================================
// Styles - Theme-aware functions
// =============================================================================
// Match tldraw minimap dimensions
const MINIMAP_WIDTH = 200;
const MINIMAP_HEIGHT = 100;
const MINIMAP_BOTTOM = 40; // tldraw minimap position from bottom
const MINIMAP_LEFT = 8; // tldraw minimap position from left
const STACK_GAP = 8; // gap between network panel and minimap
const getStyles = (isDarkMode: boolean, displayMode: DisplayMode) => ({
// Container - positioned bottom LEFT, directly above the tldraw minimap
container: {
position: 'fixed' as const,
// Stack directly above tldraw minimap: minimap_bottom + minimap_height + gap
bottom: displayMode === 'maximized' ? '0' : `${MINIMAP_BOTTOM + MINIMAP_HEIGHT + STACK_GAP}px`,
left: displayMode === 'maximized' ? '0' : `${MINIMAP_LEFT}px`,
right: displayMode === 'maximized' ? '0' : 'auto',
top: displayMode === 'maximized' ? '0' : 'auto',
zIndex: displayMode === 'maximized' ? 10000 : 1000,
display: 'flex',
flexDirection: 'column' as const,
alignItems: displayMode === 'maximized' ? 'center' : 'flex-start',
justifyContent: displayMode === 'maximized' ? 'center' : 'flex-start',
gap: '8px',
backgroundColor: displayMode === 'maximized' ? 'rgba(0, 0, 0, 0.5)' : 'transparent',
pointerEvents: displayMode === 'maximized' ? 'auto' as const : 'none' as const,
},
// Main panel
panel: {
backgroundColor: isDarkMode ? 'rgba(20, 20, 25, 0.95)' : 'rgba(255, 255, 255, 0.98)',
borderRadius: displayMode === 'maximized' ? '16px' : '12px',
boxShadow: isDarkMode
? '0 4px 20px rgba(0, 0, 0, 0.4)'
: displayMode === 'maximized'
? '0 8px 40px rgba(0, 0, 0, 0.3)'
: '0 4px 20px rgba(0, 0, 0, 0.15)',
overflow: 'hidden',
transition: 'all 0.3s ease',
border: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
pointerEvents: 'auto' as const,
// Sphere-like gradient background for the graph area
background: isDarkMode
? 'radial-gradient(ellipse at center, rgba(40, 40, 50, 0.95) 0%, rgba(20, 20, 25, 0.98) 100%)'
: 'radial-gradient(ellipse at center, rgba(255, 255, 255, 0.98) 0%, rgba(245, 245, 250, 0.98) 100%)',
},
// Minimized state - small icon
panelMinimized: {
width: '40px',
height: '40px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
background: isDarkMode
? 'radial-gradient(circle, rgba(60, 60, 80, 0.9) 0%, rgba(30, 30, 40, 0.95) 100%)'
: 'radial-gradient(circle, rgba(255, 255, 255, 0.95) 0%, rgba(240, 240, 245, 0.98) 100%)',
boxShadow: isDarkMode
? '0 2px 12px rgba(100, 100, 255, 0.2), inset 0 0 20px rgba(100, 100, 255, 0.1)'
: '0 2px 12px rgba(0, 0, 0, 0.15), inset 0 0 20px rgba(100, 100, 255, 0.05)',
border: isDarkMode ? '1px solid rgba(100, 100, 255, 0.3)' : '1px solid rgba(100, 100, 255, 0.2)',
},
// Normal state dimensions - match tldraw minimap width
panelNormal: {
width: `${MINIMAP_WIDTH}px`,
maxHeight: '200px',
},
// Maximized state dimensions
panelMaximized: {
width: '90vw',
maxWidth: '800px',
maxHeight: '80vh',
},
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: displayMode === 'maximized' ? '12px 16px' : '8px 12px',
borderBottom: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
backgroundColor: isDarkMode ? 'rgba(255, 255, 255, 0.03)' : 'rgba(0, 0, 0, 0.02)',
},
title: {
fontSize: displayMode === 'maximized' ? '14px' : '12px',
fontWeight: 600,
color: isDarkMode ? '#e0e0e0' : '#374151',
margin: 0,
display: 'flex',
alignItems: 'center',
gap: '8px',
},
headerButtons: {
display: 'flex',
gap: '4px',
},
iconButton: {
width: displayMode === 'maximized' ? '32px' : '28px',
height: displayMode === 'maximized' ? '32px' : '28px',
border: 'none',
background: 'none',
borderRadius: '6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: displayMode === 'maximized' ? '16px' : '14px',
color: isDarkMode ? '#a0a0a0' : '#6b7280',
transition: 'background-color 0.15s, color 0.15s',
},
canvas: {
display: 'block',
// Sphere-like inner gradient
background: isDarkMode
? 'radial-gradient(ellipse at center, rgba(50, 50, 70, 0.3) 0%, transparent 70%)'
: 'radial-gradient(ellipse at center, rgba(200, 200, 255, 0.15) 0%, transparent 70%)',
},
tooltip: {
position: 'absolute' as const,
backgroundColor: isDarkMode ? 'rgba(0, 0, 0, 0.9)' : 'rgba(255, 255, 255, 0.98)',
color: isDarkMode ? '#fff' : '#1f2937',
padding: '8px 12px',
borderRadius: '8px',
fontSize: '12px',
pointerEvents: 'none' as const,
whiteSpace: 'nowrap' as const,
zIndex: 1001,
transform: 'translate(-50%, -100%)',
marginTop: '-10px',
boxShadow: isDarkMode ? '0 4px 12px rgba(0, 0, 0, 0.3)' : '0 4px 12px rgba(0, 0, 0, 0.15)',
border: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
},
minimizedIcon: {
fontSize: '18px',
filter: 'drop-shadow(0 0 4px rgba(100, 100, 255, 0.4))',
},
// Network stats in maximized view
statsBar: {
display: 'flex',
gap: '16px',
padding: '8px 16px',
borderBottom: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
backgroundColor: isDarkMode ? 'rgba(255, 255, 255, 0.02)' : 'rgba(0, 0, 0, 0.01)',
fontSize: '11px',
color: isDarkMode ? '#888' : '#666',
},
statItem: {
display: 'flex',
alignItems: 'center',
gap: '4px',
},
statValue: {
fontWeight: 600,
color: isDarkMode ? '#a0a0ff' : '#4f46e5',
},
});
// =============================================================================
// Component
// =============================================================================
export function NetworkGraphMinimap({
nodes,
edges,
myConnections: _myConnections,
currentUserId,
onConnect,
onDisconnect,
onNodeClick,
onGoToUser,
onFollowUser,
onOpenProfile,
onEdgeClick,
onExpandClick,
width: propWidth = MINIMAP_WIDTH - 16, // Account for padding
height: propHeight = 120, // Compact height to match minimap proportions
isCollapsed = false,
onToggleCollapse,
isDarkMode = false,
}: NetworkGraphMinimapProps) {
const svgRef = useRef<SVGSVGElement>(null);
const simulationRef = useRef<d3.Simulation<SimulationNode, SimulationLink> | null>(null);
const simNodesRef = useRef<SimulationNode[]>([]);
const simLinksRef = useRef<SimulationLink[]>([]);
const isInitializedRef = useRef(false);
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [selectedNode, setSelectedNode] = useState<{ node: GraphNode; x: number; y: number } | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
// Stable callback for closing search modal (prevents ESC handler issues)
const handleCloseSearch = useCallback(() => {
setIsSearchOpen(false);
}, []);
// Three-state display mode: minimized, normal, maximized
const [displayMode, setDisplayMode] = useState<DisplayMode>(isCollapsed ? 'minimized' : 'normal');
// Sync with legacy isCollapsed prop
useEffect(() => {
if (isCollapsed && displayMode !== 'minimized') {
setDisplayMode('minimized');
}
}, [isCollapsed]);
// Calculate dimensions based on display mode
const { width, height } = useMemo(() => {
switch (displayMode) {
case 'minimized':
return { width: 0, height: 0 };
case 'normal':
return { width: propWidth, height: propHeight };
case 'maximized':
return {
width: Math.min(700, window.innerWidth * 0.85),
height: Math.min(500, window.innerHeight * 0.6)
};
default:
return { width: propWidth, height: propHeight };
}
}, [displayMode, propWidth, propHeight]);
// Get theme-aware styles
const styles = useMemo(() => getStyles(isDarkMode, displayMode), [isDarkMode, displayMode]);
// Network stats for maximized view
const networkStats = useMemo(() => {
const inRoomCount = nodes.filter(n => n.isInRoom).length;
const connectionCount = edges.length;
const mutualCount = edges.filter(e => e.isMutual).length;
const trustedCount = edges.filter(e => e.trustLevel === 'trusted').length;
return { inRoomCount, connectionCount, mutualCount, trustedCount, totalNodes: nodes.length };
}, [nodes, edges]);
// Cleanup simulation on unmount
useEffect(() => {
return () => {
if (simulationRef.current) {
simulationRef.current.stop();
simulationRef.current = null;
}
isInitializedRef.current = false;
simNodesRef.current = [];
simLinksRef.current = [];
};
}, []);
// Initialize and update the D3 simulation - STABLE VERSION
// This effect uses refs to persist simulation state and only updates incrementally
useEffect(() => {
if (!svgRef.current || displayMode === 'minimized' || nodes.length === 0) return;
const svg = d3.select(svgRef.current);
// Check if we need to initialize or just update
const needsInit = !isInitializedRef.current || !simulationRef.current;
const nodeMap = new Map(simNodesRef.current.map(n => [n.id, n]));
if (needsInit) {
// Full initialization - only happens once
svg.selectAll('*').remove();
// Create simulation nodes, preserving existing positions if available
simNodesRef.current = nodes.map(n => {
const existing = nodeMap.get(n.id);
return {
...n,
x: existing?.x ?? width / 2 + (Math.random() - 0.5) * 100,
y: existing?.y ?? height / 2 + (Math.random() - 0.5) * 100,
vx: existing?.vx ?? 0,
vy: existing?.vy ?? 0,
};
});
const newNodeMap = new Map(simNodesRef.current.map(n => [n.id, n]));
simLinksRef.current = edges
.filter(e => newNodeMap.has(e.source) && newNodeMap.has(e.target))
.map(e => ({
source: newNodeMap.get(e.source)!,
target: newNodeMap.get(e.target)!,
id: e.id,
isMutual: e.isMutual,
}));
// Create the simulation with smooth, stable parameters
const simulation = d3.forceSimulation<SimulationNode>(simNodesRef.current)
.force('link', d3.forceLink<SimulationNode, SimulationLink>(simLinksRef.current)
.id(d => d.id)
.distance(displayMode === 'maximized' ? 80 : 50)
.strength(0.5))
.force('charge', d3.forceManyBody()
.strength(displayMode === 'maximized' ? -150 : -100)
.distanceMax(displayMode === 'maximized' ? 300 : 200))
.force('center', d3.forceCenter(width / 2, height / 2).strength(0.05))
.force('collision', d3.forceCollide().radius(d => (d as SimulationNode).isCurrentUser ? 14 : 10))
// Gentler alpha decay for smoother settling
.alphaDecay(0.02)
// Higher alpha min so it stops sooner
.alphaMin(0.05)
// Add velocity decay for smoother movement
.velocityDecay(0.4);
simulationRef.current = simulation;
isInitializedRef.current = true;
} else {
// Incremental update - preserve existing node positions
const existingNodeMap = new Map(simNodesRef.current.map(n => [n.id, n]));
// Update existing nodes and add new ones
const newNodes: SimulationNode[] = nodes.map(n => {
const existing = existingNodeMap.get(n.id);
if (existing) {
// Update properties but keep position
return { ...existing, ...n, x: existing.x, y: existing.y, vx: existing.vx, vy: existing.vy };
}
// New node - place near center with slight randomness
return {
...n,
x: width / 2 + (Math.random() - 0.5) * 50,
y: height / 2 + (Math.random() - 0.5) * 50,
};
});
simNodesRef.current = newNodes;
const newNodeMap = new Map(newNodes.map(n => [n.id, n]));
simLinksRef.current = edges
.filter(e => newNodeMap.has(e.source) && newNodeMap.has(e.target))
.map(e => ({
source: newNodeMap.get(e.source)!,
target: newNodeMap.get(e.target)!,
id: e.id,
isMutual: e.isMutual,
}));
// Update simulation with new nodes/links
simulationRef.current!
.nodes(simNodesRef.current)
.force('link', d3.forceLink<SimulationNode, SimulationLink>(simLinksRef.current)
.id(d => d.id)
.distance(displayMode === 'maximized' ? 80 : 50)
.strength(0.5))
.force('center', d3.forceCenter(width / 2, height / 2).strength(0.05));
// Gentle reheat to settle new nodes
simulationRef.current!.alpha(0.3).restart();
// Re-render the graph with updated data
svg.selectAll('*').remove();
}
const simulation = simulationRef.current!;
// Create container group
const g = svg.append('g');
// Create arrow marker definitions for edges
const defs = svg.append('defs');
// Arrow marker for regular edges (grey)
defs.append('marker')
.attr('id', 'arrow-grey')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 18)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-4L10,0L0,4')
.attr('fill', 'rgba(150, 150, 150, 0.6)');
// Arrow marker for connected (yellow)
defs.append('marker')
.attr('id', 'arrow-connected')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 18)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-4L10,0L0,4')
.attr('fill', 'rgba(234, 179, 8, 0.8)');
// Arrow marker for trusted (green)
defs.append('marker')
.attr('id', 'arrow-trusted')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 18)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-4L10,0L0,4')
.attr('fill', 'rgba(34, 197, 94, 0.8)');
// Helper to get edge color based on trust level
const getEdgeColor = (d: SimulationLink) => {
const edge = edges.find(e => e.id === d.id);
if (!edge) return 'rgba(150, 150, 150, 0.4)';
// Use effective trust level for mutual connections, otherwise the edge's trust level
const level = edge.effectiveTrustLevel || edge.trustLevel;
if (level === 'trusted') {
return 'rgba(34, 197, 94, 0.7)'; // green
} else if (level === 'connected') {
return 'rgba(234, 179, 8, 0.7)'; // yellow
}
return 'rgba(150, 150, 150, 0.4)';
};
// Helper to get arrow marker based on trust level
const getArrowMarker = (d: SimulationLink) => {
const edge = edges.find(e => e.id === d.id);
if (!edge) return 'url(#arrow-grey)';
const level = edge.effectiveTrustLevel || edge.trustLevel;
if (level === 'trusted') return 'url(#arrow-trusted)';
if (level === 'connected') return 'url(#arrow-connected)';
return 'url(#arrow-grey)';
};
// Create edges as paths (lines) with arrow markers
const link = g.append('g')
.attr('class', 'links')
.selectAll('line')
.data(simLinksRef.current)
.join('line')
.attr('stroke', d => getEdgeColor(d))
.attr('stroke-width', d => d.isMutual ? (displayMode === 'maximized' ? 3 : 2.5) : (displayMode === 'maximized' ? 2 : 1.5))
.attr('marker-end', d => getArrowMarker(d))
.style('cursor', 'pointer')
.on('click', (event, d) => {
event.stopPropagation();
const edge = edges.find(e => e.id === d.id);
if (edge && onEdgeClick) {
onEdgeClick(edge);
}
});
// Helper to get node color - uses the user's profile/presence color
const getNodeColor = (d: SimulationNode) => {
// Use room presence color (user's profile color) if available
if (d.roomPresenceColor) {
return d.roomPresenceColor;
}
// Use avatar color as fallback
if (d.avatarColor) {
return d.avatarColor;
}
// Default grey for users without a color
return '#9ca3af';
};
// Node sizes based on display mode
const nodeRadius = displayMode === 'maximized' ? 10 : 6;
const currentUserRadius = displayMode === 'maximized' ? 14 : 8;
// Create nodes
const node = g.append('g')
.attr('class', 'nodes')
.selectAll('circle')
.data(simNodesRef.current)
.join('circle')
.attr('r', d => d.isCurrentUser ? currentUserRadius : nodeRadius)
.attr('fill', d => getNodeColor(d))
.attr('stroke', d => d.isInRoom ? 'rgba(100, 200, 255, 0.8)' : 'transparent')
.attr('stroke-width', d => d.isInRoom ? 2 : 0)
.style('cursor', 'pointer')
.style('filter', d => d.isInRoom ? 'drop-shadow(0 0 4px rgba(100, 200, 255, 0.5))' : 'none')
.on('mouseenter', (event, d) => {
const rect = svgRef.current!.getBoundingClientRect();
const name = d.displayName || d.username;
const status = d.isInRoom ? ' (in room)' : '';
setTooltip({
x: event.clientX - rect.left,
y: event.clientY - rect.top,
text: d.isCurrentUser ? `${name} (you)` : `${name}${status}`,
});
})
.on('mouseleave', () => {
setTooltip(null);
})
.on('click', (event, d) => {
event.stopPropagation();
// Don't show popup for current user
if (d.isCurrentUser) {
if (onNodeClick) onNodeClick(d);
return;
}
// Show dropdown menu for all other users
const rect = svgRef.current!.getBoundingClientRect();
setSelectedNode({
node: d,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
});
})
.call(d3.drag<SVGCircleElement, SimulationNode>()
.on('start', (event, d) => {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on('drag', (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on('end', (event, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}) as any);
// Update positions on tick
simulation.on('tick', () => {
link
.attr('x1', d => (d.source as SimulationNode).x!)
.attr('y1', d => (d.source as SimulationNode).y!)
.attr('x2', d => (d.target as SimulationNode).x!)
.attr('y2', d => (d.target as SimulationNode).y!);
const margin = displayMode === 'maximized' ? 12 : 8;
node
.attr('cx', d => Math.max(margin, Math.min(width - margin, d.x!)))
.attr('cy', d => Math.max(margin, Math.min(height - margin, d.y!)));
});
// Stop simulation when it stabilizes (alpha reaches alphaMin)
simulation.on('end', () => {
// Simulation has stabilized, nodes will stay in place unless dragged
// Don't call stop() - let it stay ready for interactions
});
// Cleanup function - only stop if component unmounts
return () => {
// Don't reset simulation on every re-render
// Only cleanup on actual unmount
};
}, [nodes, edges, width, height, displayMode, onNodeClick, onEdgeClick]);
// Handle display mode changes
const handleMinimize = useCallback(() => {
setDisplayMode('minimized');
onToggleCollapse?.();
}, [onToggleCollapse]);
const handleNormal = useCallback(() => {
setDisplayMode('normal');
}, []);
const handleMaximize = useCallback(() => {
setDisplayMode('maximized');
onExpandClick?.();
}, [onExpandClick]);
const handleMinimizedClick = useCallback(() => {
setDisplayMode('normal');
onToggleCollapse?.();
}, [onToggleCollapse]);
// Handle ESC to close maximized view
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && displayMode === 'maximized') {
setDisplayMode('normal');
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [displayMode]);
// Minimized state - small circular icon
if (displayMode === 'minimized') {
return (
<div style={styles.container}>
<div
style={{ ...styles.panel, ...styles.panelMinimized }}
onClick={handleMinimizedClick}
title="Show social network"
>
<span style={styles.minimizedIcon}>🕸</span>
</div>
</div>
);
}
// Get panel size styles based on display mode
const panelSizeStyle = displayMode === 'maximized' ? styles.panelMaximized : styles.panelNormal;
return (
<div
style={styles.container}
onClick={displayMode === 'maximized' ? handleNormal : undefined}
>
<div
style={{ ...styles.panel, ...panelSizeStyle }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div style={styles.header}>
<h3 style={styles.title}>
<span>🕸</span>
Social Network
{displayMode === 'maximized' && (
<span style={{
fontSize: '10px',
padding: '2px 6px',
borderRadius: '10px',
background: isDarkMode ? 'rgba(100, 100, 255, 0.2)' : 'rgba(100, 100, 255, 0.1)',
color: isDarkMode ? '#a0a0ff' : '#4f46e5',
marginLeft: '8px',
}}>
{networkStats.totalNodes} people
</span>
)}
</h3>
<div style={styles.headerButtons}>
{/* Search button */}
<button
style={styles.iconButton}
onClick={() => setIsSearchOpen(true)}
title="Find people"
onMouseEnter={(e) => { e.currentTarget.style.background = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'none' }}
>
🔍
</button>
{/* Maximize/Restore button */}
{displayMode === 'normal' && (
<button
style={styles.iconButton}
onClick={handleMaximize}
title="Expand to full view"
onMouseEnter={(e) => { e.currentTarget.style.background = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'none' }}
>
</button>
)}
{displayMode === 'maximized' && (
<button
style={styles.iconButton}
onClick={handleNormal}
title="Restore to small view"
onMouseEnter={(e) => { e.currentTarget.style.background = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'none' }}
>
</button>
)}
{/* Minimize button */}
<button
style={styles.iconButton}
onClick={handleMinimize}
title="Minimize"
onMouseEnter={(e) => { e.currentTarget.style.background = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'none' }}
>
</button>
{/* Close button (maximized only) */}
{displayMode === 'maximized' && (
<button
style={styles.iconButton}
onClick={handleNormal}
title="Close (Esc)"
onMouseEnter={(e) => { e.currentTarget.style.background = isDarkMode ? 'rgba(255,100,100,0.2)' : 'rgba(255,0,0,0.1)' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'none' }}
>
</button>
)}
</div>
</div>
{/* Stats bar (maximized only) */}
{displayMode === 'maximized' && (
<div style={styles.statsBar}>
<div style={styles.statItem}>
<span>👥</span>
<span>In room:</span>
<span style={styles.statValue}>{networkStats.inRoomCount}</span>
</div>
<div style={styles.statItem}>
<span>🔗</span>
<span>Connections:</span>
<span style={styles.statValue}>{networkStats.connectionCount}</span>
</div>
<div style={styles.statItem}>
<span>🤝</span>
<span>Mutual:</span>
<span style={styles.statValue}>{networkStats.mutualCount}</span>
</div>
<div style={styles.statItem}>
<span></span>
<span>Trusted:</span>
<span style={styles.statValue}>{networkStats.trustedCount}</span>
</div>
</div>
)}
{/* 3D View for maximized mode */}
{displayMode === 'maximized' ? (
<Suspense
fallback={
<div
style={{
width: '100%',
height: height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: isDarkMode
? 'radial-gradient(ellipse at center, #1a1a2e 0%, #0d0d1a 100%)'
: 'radial-gradient(ellipse at center, #f8f8ff 0%, #e8e8f0 100%)',
color: isDarkMode ? '#888' : '#666',
fontSize: '14px',
}}
>
Loading 3D view...
</div>
}
>
<div style={{ width: '100%', height: height }}>
<NetworkGraph3D
nodes={nodes}
edges={edges}
currentUserId={currentUserId}
onNodeClick={onNodeClick}
onConnect={onConnect}
onZoomToUser={onGoToUser}
onViewAsUser={onFollowUser}
isDarkMode={isDarkMode}
sphereRadius={3}
/>
</div>
</Suspense>
) : (
/* 2D SVG View for normal mode */
<div style={{ position: 'relative' }} onClick={() => setSelectedNode(null)}>
<svg
ref={svgRef}
width={width}
height={height}
style={styles.canvas}
/>
{tooltip && (
<div
style={{
...styles.tooltip,
left: tooltip.x,
top: tooltip.y,
}}
>
{tooltip.text}
</div>
)}
{/* User action dropdown menu when clicking a node */}
{selectedNode && !selectedNode.node.isCurrentUser && (
<div
style={{
position: 'absolute',
left: Math.min(selectedNode.x, width - 160),
top: Math.max(selectedNode.y - 10, 10),
backgroundColor: isDarkMode ? '#1e1e2e' : 'white',
borderRadius: '8px',
boxShadow: isDarkMode ? '0 4px 12px rgba(0, 0, 0, 0.4)' : '0 4px 12px rgba(0, 0, 0, 0.15)',
padding: '6px',
zIndex: 1002,
minWidth: '150px',
border: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Connect option - only for non-anonymous users */}
{!selectedNode.node.isAnonymous && (
<button
onClick={async () => {
setIsConnecting(true);
try {
const userId = selectedNode.node.username || selectedNode.node.id;
await onConnect(userId, 'connected');
} catch (err) {
console.error('Failed to connect:', err);
}
setSelectedNode(null);
setIsConnecting(false);
}}
disabled={isConnecting}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '11px',
backgroundColor: 'transparent',
color: isDarkMode ? '#fbbf24' : '#92400e',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<span>🔗</span> Connect with {selectedNode.node.displayName || selectedNode.node.username}
</button>
)}
{/* Navigate option - only for in-room users */}
{selectedNode.node.isInRoom && onGoToUser && (
<button
onClick={() => {
onGoToUser(selectedNode.node);
setSelectedNode(null);
}}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '11px',
backgroundColor: 'transparent',
color: isDarkMode ? '#a0a0ff' : '#4f46e5',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<span>📍</span> Navigate to {selectedNode.node.displayName || selectedNode.node.username}
</button>
)}
{/* Screenfollow option - only for in-room users */}
{selectedNode.node.isInRoom && onFollowUser && (
<button
onClick={() => {
onFollowUser(selectedNode.node);
setSelectedNode(null);
}}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '11px',
backgroundColor: 'transparent',
color: isDarkMode ? '#60a5fa' : '#2563eb',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<span>👁</span> Screenfollow {selectedNode.node.displayName || selectedNode.node.username}
</button>
)}
{/* Open profile option - only for non-anonymous users */}
{!selectedNode.node.isAnonymous && onOpenProfile && (
<button
onClick={() => {
onOpenProfile(selectedNode.node);
setSelectedNode(null);
}}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '11px',
backgroundColor: 'transparent',
color: isDarkMode ? '#e0e0e0' : '#374151',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<span>👤</span> Open {selectedNode.node.displayName || selectedNode.node.username}'s profile
</button>
)}
</div>
)}
</div>
)}
</div>
<UserSearchModal
isOpen={isSearchOpen}
onClose={handleCloseSearch}
onConnect={onConnect}
onDisconnect={onDisconnect ? (userId) => {
// Find the connection ID for this user
const edge = edges.find(e =>
(e.source === currentUserId && e.target === userId) ||
(e.target === currentUserId && e.source === userId)
);
if (edge && onDisconnect) {
return onDisconnect(edge.id);
}
return Promise.resolve();
} : undefined}
currentUserId={currentUserId}
/>
</div>
);
}
export default NetworkGraphMinimap;

View File

@ -0,0 +1,430 @@
/**
* NetworkGraphPanel Component
*
* Wrapper that integrates the NetworkGraphMinimap with tldraw.
* Extracts room participants from the editor and provides connection actions.
*/
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useEditor, useValue } from 'tldraw';
import { NetworkGraphMinimap } from './NetworkGraphMinimap';
import { useNetworkGraph } from './useNetworkGraph';
import { useAuth } from '../../context/AuthContext';
import type { GraphEdge, TrustLevel } from '../../lib/networking';
// =============================================================================
// Broadcast Mode Indicator Component
// =============================================================================
interface BroadcastIndicatorProps {
followingUser: { id: string; username: string; color?: string } | null;
onStop: () => void;
isDarkMode: boolean;
}
function BroadcastIndicator({ followingUser, onStop, isDarkMode }: BroadcastIndicatorProps) {
if (!followingUser) return null;
return (
<div
style={{
position: 'fixed',
top: '12px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 10000,
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 16px',
background: isDarkMode
? 'linear-gradient(135deg, rgba(168, 85, 247, 0.95), rgba(139, 92, 246, 0.95))'
: 'linear-gradient(135deg, rgba(168, 85, 247, 0.95), rgba(139, 92, 246, 0.95))',
borderRadius: '12px',
boxShadow: '0 4px 20px rgba(168, 85, 247, 0.4)',
border: '1px solid rgba(255, 255, 255, 0.2)',
animation: 'pulse-glow 2s ease-in-out infinite',
}}
>
<style>
{`
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 4px 20px rgba(168, 85, 247, 0.4); }
50% { box-shadow: 0 4px 30px rgba(168, 85, 247, 0.6); }
}
`}
</style>
{/* Live indicator */}
<div
style={{
width: '10px',
height: '10px',
borderRadius: '50%',
background: '#ef4444',
animation: 'pulse 1.5s ease-in-out infinite',
}}
/>
<style>
{`
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(0.9); }
}
`}
</style>
{/* User avatar */}
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: followingUser.color || '#6366f1',
border: '2px solid rgba(255, 255, 255, 0.5)',
}}
/>
{/* Text */}
<div style={{ color: '#fff', fontSize: '13px', fontWeight: 500 }}>
<span style={{ opacity: 0.8 }}>Viewing as</span>{' '}
<strong>{followingUser.username}</strong>
</div>
{/* Exit hint */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
marginLeft: '8px',
padding: '4px 8px',
background: 'rgba(0, 0, 0, 0.2)',
borderRadius: '6px',
}}
>
<kbd
style={{
padding: '2px 6px',
background: 'rgba(255, 255, 255, 0.2)',
borderRadius: '4px',
fontSize: '11px',
color: '#fff',
fontFamily: 'monospace',
}}
>
ESC
</kbd>
<span style={{ color: 'rgba(255, 255, 255, 0.7)', fontSize: '11px' }}>to exit</span>
</div>
{/* Close button */}
<button
onClick={onStop}
style={{
marginLeft: '4px',
width: '24px',
height: '24px',
borderRadius: '50%',
border: 'none',
background: 'rgba(255, 255, 255, 0.2)',
color: '#fff',
fontSize: '14px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)')}
>
</button>
</div>
);
}
// =============================================================================
// Types
// =============================================================================
interface NetworkGraphPanelProps {
onExpand?: () => void;
}
// =============================================================================
// Component
// =============================================================================
export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
const editor = useEditor();
const { session } = useAuth();
// Start collapsed on mobile for less cluttered UI
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640;
const [isCollapsed, setIsCollapsed] = useState(isMobile);
const [selectedEdge, setSelectedEdge] = useState<GraphEdge | null>(null);
// Broadcast mode state - tracks who we're following
const [followingUser, setFollowingUser] = useState<{
id: string;
username: string;
color?: string;
} | null>(null);
// Detect dark mode
const [isDarkMode, setIsDarkMode] = useState(
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
);
// Listen for theme changes
useEffect(() => {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
setIsDarkMode(document.documentElement.classList.contains('dark'));
}
});
});
observer.observe(document.documentElement, { attributes: true });
return () => observer.disconnect();
}, []);
// Stop following user - cleanup function
const stopFollowingUser = useCallback(() => {
if (!editor) return;
editor.stopFollowingUser();
setFollowingUser(null);
// Remove followId from URL if present
const url = new URL(window.location.href);
if (url.searchParams.has('followId')) {
url.searchParams.delete('followId');
window.history.replaceState(null, '', url.toString());
}
}, [editor]);
// Keyboard handler for ESC and X to exit broadcast mode
useEffect(() => {
if (!followingUser) return;
const handleKeyDown = (e: KeyboardEvent) => {
// ESC or X (lowercase or uppercase) stops following
if (e.key === 'Escape' || e.key === 'x' || e.key === 'X') {
e.preventDefault();
e.stopPropagation();
stopFollowingUser();
}
};
// Use capture phase to intercept before tldraw
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => {
window.removeEventListener('keydown', handleKeyDown, { capture: true });
};
}, [followingUser, stopFollowingUser]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (followingUser && editor) {
editor.stopFollowingUser();
}
};
}, [followingUser, editor]);
// Get collaborators from tldraw
const collaborators = useValue(
'collaborators',
() => editor.getCollaborators(),
[editor]
);
const myColor = useValue('myColor', () => editor.user.getColor(), [editor]);
const myName = useValue('myName', () => editor.user.getName() || 'Anonymous', [editor]);
// Convert collaborators to room participants format
const roomParticipants = useMemo(() => {
// Add current user
const participants = [
{
id: session.username || 'me', // Use CryptID username if available
username: myName,
color: myColor,
},
];
// Add collaborators - TLInstancePresence has userId and userName
collaborators.forEach((c: any) => {
participants.push({
id: c.userId || c.id,
username: c.userName || 'Anonymous',
color: c.color,
});
});
return participants;
}, [session.username, myName, myColor, collaborators]);
// Use the network graph hook
const {
nodes,
edges,
myConnections,
isLoading,
error,
connect,
disconnect,
} = useNetworkGraph({
roomParticipants,
refreshInterval: 30000, // Refresh every 30 seconds
useCache: true,
});
// Handle connect with optional trust level
const handleConnect = useCallback(async (userId: string, trustLevel: TrustLevel = 'connected') => {
await connect(userId, trustLevel);
}, [connect]);
// Handle disconnect
const handleDisconnect = useCallback(async (connectionId: string) => {
await disconnect(connectionId);
}, [disconnect]);
// Handle node click
const handleNodeClick = useCallback((_node: any) => {
// Could open a profile modal or navigate to user
}, []);
// Handle going to a user's cursor on canvas (navigate/pan to their location)
const handleGoToUser = useCallback((node: any) => {
if (!editor) return;
// Find the collaborator's cursor position
// TLInstancePresence has userId and userName properties
const targetCollaborator = collaborators.find((c: any) =>
c.id === node.id ||
c.userId === node.id ||
c.userName === node.username
);
if (targetCollaborator && targetCollaborator.cursor) {
// Pan to the user's cursor position
const { x, y } = targetCollaborator.cursor;
editor.centerOnPoint({ x, y });
} else {
// If no cursor position, try to find any presence data
}
}, [editor, collaborators]);
// Handle screen following a user (camera follows their view)
const handleFollowUser = useCallback((node: any) => {
if (!editor) return;
// Find the collaborator to follow
// TLInstancePresence has userId and userName properties
const targetCollaborator = collaborators.find((c: any) =>
c.id === node.id ||
c.userId === node.id ||
c.userName === node.username
);
if (targetCollaborator) {
// Use tldraw's built-in follow functionality - needs userId
const userId = targetCollaborator.userId || targetCollaborator.id;
editor.startFollowingUser(userId);
// Set state to show broadcast indicator and enable keyboard exit
setFollowingUser({
id: userId,
username: node.username || node.displayName || 'User',
color: targetCollaborator.color || node.avatarColor || node.roomPresenceColor,
});
// Optionally add followId to URL for deep linking
const url = new URL(window.location.href);
url.searchParams.set('followId', userId);
window.history.replaceState(null, '', url.toString());
} else {
}
}, [editor, collaborators]);
// Handle opening a user's profile
const handleOpenProfile = useCallback((node: any) => {
// Open user profile in a new tab or modal
const username = node.username || node.id;
// Navigate to user profile page
window.open(`/profile/${username}`, '_blank');
}, []);
// Handle edge click
const handleEdgeClick = useCallback((edge: GraphEdge) => {
setSelectedEdge(edge);
// Could open an edge metadata editor modal
}, []);
// Handle expand to full 3D view
const handleExpand = useCallback(() => {
if (onExpand) {
onExpand();
} else {
// Default: open in new tab
window.open('/graph', '_blank');
}
}, [onExpand]);
// Show loading state briefly
if (isLoading && nodes.length === 0) {
return (
<div style={{
position: 'fixed',
bottom: '60px',
right: '10px',
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.15)',
}}>
Loading network...
</div>
);
}
return (
<>
{/* Broadcast mode indicator - shows when following a user */}
<BroadcastIndicator
followingUser={followingUser}
onStop={stopFollowingUser}
isDarkMode={isDarkMode}
/>
{/* Network graph minimap */}
<NetworkGraphMinimap
nodes={nodes}
edges={edges}
myConnections={myConnections}
currentUserId={session.username}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onNodeClick={handleNodeClick}
onGoToUser={handleGoToUser}
onFollowUser={handleFollowUser}
onOpenProfile={handleOpenProfile}
onEdgeClick={handleEdgeClick}
onExpandClick={handleExpand}
isCollapsed={isCollapsed}
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
isDarkMode={isDarkMode}
/>
</>
);
}
export default NetworkGraphPanel;

View File

@ -0,0 +1,379 @@
/**
* UserSearchModal Component
*
* Modal for searching and connecting with other users.
* Features:
* - Fuzzy search by username/display name
* - Shows connection status
* - One-click connect/disconnect
* - Shows mutual connections count
*/
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { searchUsers, type UserSearchResult } from '../../lib/networking';
// =============================================================================
// Types
// =============================================================================
interface UserSearchModalProps {
isOpen: boolean;
onClose: () => void;
onConnect: (userId: string) => Promise<void>;
onDisconnect?: (userId: string) => Promise<void>;
currentUserId?: string;
}
// =============================================================================
// Styles
// =============================================================================
const styles = {
overlay: {
position: 'fixed' as const,
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000,
},
modal: {
backgroundColor: 'var(--color-background, #fff)',
borderRadius: '12px',
width: '90%',
maxWidth: '480px',
maxHeight: '70vh',
display: 'flex',
flexDirection: 'column' as const,
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.2)',
overflow: 'hidden',
},
header: {
padding: '16px 20px',
borderBottom: '1px solid var(--color-border, #e0e0e0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
},
title: {
fontSize: '18px',
fontWeight: 600,
margin: 0,
color: 'var(--color-text, #1a1a2e)',
},
closeButton: {
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
color: 'var(--color-text-secondary, #666)',
padding: '4px',
lineHeight: 1,
},
searchContainer: {
padding: '16px 20px',
borderBottom: '1px solid var(--color-border, #e0e0e0)',
},
searchInput: {
width: '100%',
padding: '12px 16px',
fontSize: '16px',
border: '1px solid var(--color-border, #e0e0e0)',
borderRadius: '8px',
outline: 'none',
backgroundColor: 'var(--color-surface, #f5f5f5)',
color: 'var(--color-text, #1a1a2e)',
},
results: {
flex: 1,
overflowY: 'auto' as const,
padding: '8px 0',
},
resultItem: {
display: 'flex',
alignItems: 'center',
padding: '12px 20px',
gap: '12px',
cursor: 'pointer',
transition: 'background-color 0.15s',
},
resultItemHover: {
backgroundColor: 'var(--color-surface, #f5f5f5)',
},
avatar: {
width: '40px',
height: '40px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '16px',
fontWeight: 600,
color: '#fff',
flexShrink: 0,
},
userInfo: {
flex: 1,
minWidth: 0,
},
username: {
fontSize: '15px',
fontWeight: 500,
color: 'var(--color-text, #1a1a2e)',
whiteSpace: 'nowrap' as const,
overflow: 'hidden',
textOverflow: 'ellipsis',
},
displayName: {
fontSize: '13px',
color: 'var(--color-text-secondary, #666)',
whiteSpace: 'nowrap' as const,
overflow: 'hidden',
textOverflow: 'ellipsis',
},
mutualBadge: {
fontSize: '11px',
color: 'var(--color-text-tertiary, #999)',
marginTop: '2px',
},
connectButton: {
padding: '8px 16px',
fontSize: '13px',
fontWeight: 500,
borderRadius: '6px',
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s',
flexShrink: 0,
},
connectButtonConnect: {
backgroundColor: 'var(--color-primary, #4f46e5)',
color: '#fff',
},
connectButtonConnected: {
backgroundColor: 'var(--color-success, #22c55e)',
color: '#fff',
},
connectButtonMutual: {
backgroundColor: 'var(--color-accent, #8b5cf6)',
color: '#fff',
},
emptyState: {
padding: '40px 20px',
textAlign: 'center' as const,
color: 'var(--color-text-secondary, #666)',
},
loadingState: {
padding: '40px 20px',
textAlign: 'center' as const,
color: 'var(--color-text-secondary, #666)',
},
};
// =============================================================================
// Component
// =============================================================================
export function UserSearchModal({
isOpen,
onClose,
onConnect,
onDisconnect,
currentUserId,
}: UserSearchModalProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<UserSearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [connectingId, setConnectingId] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Focus input when modal opens
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
// Debounced search
useEffect(() => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
if (query.length < 2) {
setResults([]);
return;
}
searchTimeoutRef.current = setTimeout(async () => {
setIsLoading(true);
try {
const users = await searchUsers(query);
// Filter out current user
setResults(users.filter(u => u.id !== currentUserId));
} catch (error) {
console.error('Search failed:', error);
setResults([]);
} finally {
setIsLoading(false);
}
}, 300);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [query, currentUserId]);
// Handle connect/disconnect
const handleConnect = useCallback(async (user: UserSearchResult) => {
setConnectingId(user.id);
try {
if (user.isConnected && onDisconnect) {
await onDisconnect(user.id);
// Update local state
setResults(prev => prev.map(u =>
u.id === user.id ? { ...u, isConnected: false } : u
));
} else {
await onConnect(user.id);
// Update local state
setResults(prev => prev.map(u =>
u.id === user.id ? { ...u, isConnected: true } : u
));
}
} catch (error) {
console.error('Connection action failed:', error);
} finally {
setConnectingId(null);
}
}, [onConnect, onDisconnect]);
// Handle escape key - use a ref to avoid stale closure issues
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onCloseRef.current();
}
};
window.addEventListener('keydown', handleKeyDown, true); // Use capture phase
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [isOpen]);
if (!isOpen) return null;
const getButtonStyle = (user: UserSearchResult) => {
if (user.isConnected && user.isConnectedBack) {
return { ...styles.connectButton, ...styles.connectButtonMutual };
}
if (user.isConnected) {
return { ...styles.connectButton, ...styles.connectButtonConnected };
}
return { ...styles.connectButton, ...styles.connectButtonConnect };
};
const getButtonText = (user: UserSearchResult) => {
if (connectingId === user.id) return '...';
if (user.isConnected && user.isConnectedBack) return 'Mutual';
if (user.isConnected) return 'Connected';
return 'Connect';
};
return (
<div style={styles.overlay} onClick={onClose}>
<div style={styles.modal} onClick={e => e.stopPropagation()}>
<div style={styles.header}>
<h2 style={styles.title}>Find People</h2>
<button style={styles.closeButton} onClick={onClose}>
&times;
</button>
</div>
<div style={styles.searchContainer}>
<input
ref={inputRef}
type="text"
placeholder="Search by username..."
value={query}
onChange={e => setQuery(e.target.value)}
style={styles.searchInput}
/>
</div>
<div style={styles.results}>
{isLoading ? (
<div style={styles.loadingState}>Searching...</div>
) : results.length === 0 ? (
<div style={styles.emptyState}>
{query.length < 2
? 'Type at least 2 characters to search'
: 'No users found'
}
</div>
) : (
results.map(user => (
<div
key={user.id}
style={{
...styles.resultItem,
...(hoveredId === user.id ? styles.resultItemHover : {}),
}}
onMouseEnter={() => setHoveredId(user.id)}
onMouseLeave={() => setHoveredId(null)}
>
<div
style={{
...styles.avatar,
backgroundColor: user.avatarColor || '#6366f1',
}}
>
{(user.displayName || user.username).charAt(0).toUpperCase()}
</div>
<div style={styles.userInfo}>
<div style={styles.username}>@{user.username}</div>
{user.displayName && user.displayName !== user.username && (
<div style={styles.displayName}>{user.displayName}</div>
)}
{user.isConnectedBack && !user.isConnected && (
<div style={styles.mutualBadge}>Follows you</div>
)}
{user.mutualConnections > 0 && (
<div style={styles.mutualBadge}>
{user.mutualConnections} mutual connection{user.mutualConnections !== 1 ? 's' : ''}
</div>
)}
</div>
<button
style={getButtonStyle(user)}
onClick={() => handleConnect(user)}
disabled={connectingId === user.id}
>
{getButtonText(user)}
</button>
</div>
))
)}
</div>
</div>
</div>
);
}
export default UserSearchModal;

View File

@ -0,0 +1,12 @@
/**
* Networking Components
*
* UI components for user networking and social graph visualization.
*/
export { NetworkGraphMinimap } from './NetworkGraphMinimap';
export { NetworkGraph3D } from './NetworkGraph3D';
export { NetworkGraphPanel } from './NetworkGraphPanel';
export { UserSearchModal } from './UserSearchModal';
export { useNetworkGraph, useRoomParticipantsFromEditor } from './useNetworkGraph';
export type { RoomParticipant, NetworkGraphState, UseNetworkGraphOptions, UseNetworkGraphReturn } from './useNetworkGraph';

View File

@ -0,0 +1,507 @@
/**
* useNetworkGraph Hook
*
* Manages the network graph state for visualization:
* - Fetches user's network from the API
* - Integrates with room presence to mark active participants
* - Provides real-time updates when connections change
* - Caches graph for fast loading
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useAuth } from '../../context/AuthContext';
import {
getMyNetworkGraph,
getRoomNetworkGraph,
createConnection,
removeConnection,
getCachedGraph,
setCachedGraph,
clearGraphCache,
type NetworkGraph,
type GraphNode,
type GraphEdge,
type TrustLevel,
} from '../../lib/networking';
// =============================================================================
// Types
// =============================================================================
export interface RoomParticipant {
id: string;
username: string;
color: string; // Presence color from tldraw
}
export interface NetworkGraphState {
nodes: GraphNode[];
edges: GraphEdge[];
myConnections: string[];
isLoading: boolean;
error: string | null;
}
export interface UseNetworkGraphOptions {
// Room participants to highlight (from tldraw presence)
roomParticipants?: RoomParticipant[];
// Auto-refresh interval (ms), 0 to disable
refreshInterval?: number;
// Whether to use cached data initially
useCache?: boolean;
}
export interface UseNetworkGraphReturn extends NetworkGraphState {
// Refresh the graph from the server
refresh: () => Promise<void>;
// Connect to a user with optional trust level
connect: (userId: string, trustLevel?: TrustLevel) => Promise<void>;
// Disconnect from a user
disconnect: (connectionId: string) => Promise<void>;
// Check if connected to a user
isConnectedTo: (userId: string) => boolean;
// Get node by ID
getNode: (userId: string) => GraphNode | undefined;
// Get edges for a node
getEdgesForNode: (userId: string) => GraphEdge[];
// Nodes that are in the current room
roomNodes: GraphNode[];
// Nodes that are not in the room (shown in grey)
networkNodes: GraphNode[];
}
// =============================================================================
// Hook Implementation
// =============================================================================
export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetworkGraphReturn {
const {
roomParticipants = [],
refreshInterval = 0,
useCache = true,
} = options;
const { session } = useAuth();
const [state, setState] = useState<NetworkGraphState>({
nodes: [],
edges: [],
myConnections: [],
isLoading: true,
error: null,
});
// Create a map of room participant IDs to their colors
const participantColorMap = useMemo(() => {
const map = new Map<string, string>();
roomParticipants.forEach(p => map.set(p.id, p.color));
return map;
}, [roomParticipants]);
const participantIds = useMemo(() =>
roomParticipants.map(p => p.id),
[roomParticipants]
);
// Fetch the network graph
const fetchGraph = useCallback(async (skipCache = false) => {
// For unauthenticated users, just show room participants without network connections
if (!session.authed || !session.username) {
// Create nodes from room participants for anonymous users
const anonymousNodes: GraphNode[] = roomParticipants.map(participant => ({
id: participant.id,
username: participant.username,
displayName: participant.username,
avatarColor: participant.color,
isInRoom: true,
roomPresenceColor: participant.color,
isCurrentUser: participant.id === roomParticipants[0]?.id, // First participant is current user
isAnonymous: true,
trustLevelTo: undefined,
trustLevelFrom: undefined,
}));
setState({
nodes: anonymousNodes,
edges: [],
myConnections: [],
isLoading: false,
error: null,
});
return;
}
// Try cache first
if (useCache && !skipCache) {
const cached = getCachedGraph();
if (cached) {
setState(prev => ({
...prev,
nodes: cached.nodes.map(n => ({
...n,
isInRoom: participantIds.includes(n.id),
roomPresenceColor: participantColorMap.get(n.id),
isCurrentUser: n.username === session.username,
isAnonymous: false,
})),
edges: cached.edges,
myConnections: (cached as any).myConnections || [],
isLoading: false,
error: null,
}));
// Still fetch in background to update
}
}
try {
setState(prev => ({ ...prev, isLoading: !prev.nodes.length }));
// Double-check authentication before making API calls
// This handles race conditions where session state might not be updated yet
const currentUserId = (() => {
try {
// Session is stored as 'canvas_auth_session' by sessionPersistence.ts
const sessionStr = localStorage.getItem('canvas_auth_session');
if (sessionStr) {
const s = JSON.parse(sessionStr);
if (s.authed && s.username) return s.username;
}
} catch { /* ignore */ }
return null;
})();
if (!currentUserId) {
// Not authenticated - use room participants only
const anonymousNodes: GraphNode[] = roomParticipants.map(participant => ({
id: participant.id,
username: participant.username,
displayName: participant.username,
avatarColor: participant.color,
isInRoom: true,
roomPresenceColor: participant.color,
isCurrentUser: participant.id === roomParticipants[0]?.id,
isAnonymous: true,
trustLevelTo: undefined,
trustLevelFrom: undefined,
}));
setState({
nodes: anonymousNodes,
edges: [],
myConnections: [],
isLoading: false,
error: null,
});
return;
}
// Fetch graph, optionally scoped to room
let graph: NetworkGraph;
try {
if (participantIds.length > 0) {
graph = await getRoomNetworkGraph(participantIds);
} else {
graph = await getMyNetworkGraph();
}
} catch (apiError: any) {
// If API call fails (e.g., 401 Unauthorized), fall back to showing room participants
// Only log if it's not a 401 (which is expected for auth issues)
if (!apiError.message?.includes('401')) {
console.warn('Network graph API failed, falling back to room participants:', apiError.message);
}
const fallbackNodes: GraphNode[] = roomParticipants.map(participant => ({
id: participant.id,
username: participant.username,
displayName: participant.username,
avatarColor: participant.color,
isInRoom: true,
roomPresenceColor: participant.color,
isCurrentUser: participant.id === session.username || participant.id === roomParticipants[0]?.id,
isAnonymous: false,
trustLevelTo: undefined,
trustLevelFrom: undefined,
}));
setState({
nodes: fallbackNodes,
edges: [],
myConnections: [],
isLoading: false,
error: null, // Don't show error to user - graceful degradation
});
return;
}
// Enrich nodes with room status, current user flag, and anonymous status
const graphNodeIds = new Set(graph.nodes.map(n => n.id));
const enrichedNodes = graph.nodes.map(node => ({
...node,
isInRoom: participantIds.includes(node.id),
roomPresenceColor: participantColorMap.get(node.id),
isCurrentUser: node.username === session.username,
isAnonymous: false, // Nodes from the graph are authenticated
}));
// Always ensure the current user is in the graph, even if they have no connections
const currentUserInGraph = enrichedNodes.some(n => n.isCurrentUser);
if (!currentUserInGraph) {
// Find current user in room participants
const currentUserParticipant = roomParticipants.find(p => p.id === session.username);
if (currentUserParticipant) {
enrichedNodes.push({
id: currentUserParticipant.id,
username: currentUserParticipant.username,
displayName: currentUserParticipant.username,
avatarColor: currentUserParticipant.color,
isInRoom: true,
roomPresenceColor: currentUserParticipant.color,
isCurrentUser: true,
isAnonymous: false,
trustLevelTo: undefined,
trustLevelFrom: undefined,
});
}
}
// Add room participants who are not in the network graph as anonymous nodes
roomParticipants.forEach(participant => {
if (!graphNodeIds.has(participant.id) && participant.id !== session.username) {
// Check if this looks like an anonymous/guest ID
const isAnonymous = participant.username.startsWith('Guest') ||
participant.username === 'Anonymous' ||
!participant.id.match(/^[a-zA-Z0-9_-]+$/); // CryptID usernames are alphanumeric
enrichedNodes.push({
id: participant.id,
username: participant.username,
displayName: participant.username,
avatarColor: participant.color,
isInRoom: true,
roomPresenceColor: participant.color,
isCurrentUser: false,
isAnonymous,
trustLevelTo: undefined,
trustLevelFrom: undefined,
});
}
});
setState({
nodes: enrichedNodes,
edges: graph.edges,
myConnections: graph.myConnections,
isLoading: false,
error: null,
});
// Cache the result
setCachedGraph(graph);
} catch (error) {
setState(prev => ({
...prev,
isLoading: false,
error: (error as Error).message,
}));
}
}, [session.authed, session.username, participantIds, participantColorMap, useCache, roomParticipants]);
// Initial fetch
useEffect(() => {
fetchGraph();
}, [fetchGraph]);
// Listen for session-cleared event to immediately clear graph state
useEffect(() => {
const handleSessionCleared = () => {
clearGraphCache();
setState({
nodes: [],
edges: [],
myConnections: [],
isLoading: false,
error: null,
});
};
window.addEventListener('session-cleared', handleSessionCleared);
return () => window.removeEventListener('session-cleared', handleSessionCleared);
}, []);
// Refresh interval
useEffect(() => {
if (refreshInterval > 0) {
const interval = setInterval(() => fetchGraph(true), refreshInterval);
return () => clearInterval(interval);
}
}, [refreshInterval, fetchGraph]);
// Update room status when participants change AND add new participants immediately
useEffect(() => {
setState(prev => {
const existingNodeIds = new Set(prev.nodes.map(n => n.id));
// Update existing nodes with room status
const updatedNodes = prev.nodes.map(node => ({
...node,
isInRoom: participantIds.includes(node.id),
roomPresenceColor: participantColorMap.get(node.id),
}));
// Add any new room participants that aren't in the graph yet
roomParticipants.forEach(participant => {
if (!existingNodeIds.has(participant.id)) {
// Check if this is the current user
const isCurrentUser = participant.id === session.username;
// Check if this looks like an anonymous/guest ID
const isAnonymous = !isCurrentUser && (
participant.username.startsWith('Guest') ||
participant.username === 'Anonymous' ||
!participant.id.match(/^[a-zA-Z0-9_-]+$/)
);
updatedNodes.push({
id: participant.id,
username: participant.username,
displayName: participant.username,
avatarColor: participant.color,
isInRoom: true,
roomPresenceColor: participant.color,
isCurrentUser,
isAnonymous,
trustLevelTo: undefined,
trustLevelFrom: undefined,
});
}
});
return {
...prev,
nodes: updatedNodes,
};
});
}, [participantIds, participantColorMap, roomParticipants, session.username]);
// Connect to a user
const connect = useCallback(async (userId: string, trustLevel: TrustLevel = 'connected') => {
try {
await createConnection(userId, trustLevel);
// Refresh the graph to get updated state
await fetchGraph(true);
clearGraphCache();
} catch (error) {
setState(prev => ({
...prev,
error: (error as Error).message,
}));
throw error;
}
}, [fetchGraph]);
// Disconnect from a user
const disconnect = useCallback(async (connectionId: string) => {
try {
await removeConnection(connectionId);
// Refresh the graph to get updated state
await fetchGraph(true);
clearGraphCache();
} catch (error) {
setState(prev => ({
...prev,
error: (error as Error).message,
}));
throw error;
}
}, [fetchGraph]);
// Check if connected to a user
const isConnectedTo = useCallback((userId: string) => {
return state.myConnections.includes(userId);
}, [state.myConnections]);
// Get node by ID
const getNode = useCallback((userId: string) => {
return state.nodes.find(n => n.id === userId);
}, [state.nodes]);
// Get edges for a node
const getEdgesForNode = useCallback((userId: string) => {
return state.edges.filter(e => e.source === userId || e.target === userId);
}, [state.edges]);
// Split nodes into room vs network
const roomNodes = useMemo(() =>
state.nodes.filter(n => n.isInRoom),
[state.nodes]
);
const networkNodes = useMemo(() =>
state.nodes.filter(n => !n.isInRoom),
[state.nodes]
);
return {
...state,
refresh: () => fetchGraph(true),
connect,
disconnect,
isConnectedTo,
getNode,
getEdgesForNode,
roomNodes,
networkNodes,
};
}
// =============================================================================
// Helper Hook: Extract room participants from tldraw editor
// =============================================================================
/**
* Extract room participants from tldraw collaborators
* Use this to get the roomParticipants for useNetworkGraph
*/
export function useRoomParticipantsFromEditor(editor: any): RoomParticipant[] {
const [participants, setParticipants] = useState<RoomParticipant[]>([]);
useEffect(() => {
if (!editor) return;
const updateParticipants = () => {
try {
const collaborators = editor.getCollaborators();
const currentUser = editor.user;
const ps: RoomParticipant[] = [
// Add current user
{
id: currentUser.getId(),
username: currentUser.getName(),
color: currentUser.getColor(),
},
// Add collaborators
...collaborators.map((c: any) => ({
id: c.id || c.instanceId,
username: c.userName || 'Anonymous',
color: c.color,
})),
];
setParticipants(ps);
} catch (e) {
console.warn('Failed to get collaborators:', e);
}
};
// Initial update
updateParticipants();
// Listen for changes
// Note: tldraw doesn't have a great event for this, so we poll
const interval = setInterval(updateParticipants, 2000);
return () => clearInterval(interval);
}, [editor]);
return participants;
}

View File

@ -0,0 +1,273 @@
/**
* WorkflowPalette
*
* Sidebar palette showing available workflow blocks organized by category.
* Supports click-to-place and displays block descriptions.
*/
import React, { useState, useCallback, useMemo } from 'react'
import { Editor } from 'tldraw'
import {
getAllBlockDefinitions,
getBlocksByCategory,
} from '@/lib/workflow/blockRegistry'
import {
BlockCategory,
BlockDefinition,
CATEGORY_INFO,
} from '@/lib/workflow/types'
import {
setWorkflowBlockType,
} from '@/tools/WorkflowBlockTool'
// =============================================================================
// Types
// =============================================================================
interface WorkflowPaletteProps {
editor: Editor
isOpen: boolean
onClose: () => void
}
// =============================================================================
// Category Section Component
// =============================================================================
interface CategorySectionProps {
category: BlockCategory
blocks: BlockDefinition[]
isExpanded: boolean
onToggle: () => void
onBlockClick: (blockType: string) => void
}
const CategorySection: React.FC<CategorySectionProps> = ({
category,
blocks,
isExpanded,
onToggle,
onBlockClick,
}) => {
const info = CATEGORY_INFO[category]
return (
<div className="workflow-palette-category">
<button
className="workflow-palette-category-header"
onClick={onToggle}
style={{ borderLeftColor: info.color }}
>
<span className="workflow-palette-category-icon">{info.icon}</span>
<span className="workflow-palette-category-label">{info.label}</span>
<span className="workflow-palette-category-count">{blocks.length}</span>
<span className={`workflow-palette-chevron ${isExpanded ? 'expanded' : ''}`}>
</span>
</button>
{isExpanded && (
<div className="workflow-palette-blocks">
{blocks.map((block) => (
<BlockCard
key={block.type}
block={block}
categoryColor={info.color}
onClick={() => onBlockClick(block.type)}
/>
))}
</div>
)}
</div>
)
}
// =============================================================================
// Block Card Component
// =============================================================================
interface BlockCardProps {
block: BlockDefinition
categoryColor: string
onClick: () => void
}
const BlockCard: React.FC<BlockCardProps> = ({ block, categoryColor, onClick }) => {
const [isHovered, setIsHovered] = useState(false)
return (
<button
className="workflow-palette-block"
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
borderLeftColor: isHovered ? categoryColor : 'transparent',
}}
>
<span className="workflow-palette-block-icon">{block.icon}</span>
<div className="workflow-palette-block-content">
<span className="workflow-palette-block-name">{block.name}</span>
<span className="workflow-palette-block-description">
{block.description}
</span>
</div>
<div className="workflow-palette-block-ports">
<span className="workflow-palette-port-count" title="Inputs">
{block.inputs.length}
</span>
<span className="workflow-palette-port-count" title="Outputs">
{block.outputs.length}
</span>
</div>
</button>
)
}
// =============================================================================
// Search Bar Component
// =============================================================================
interface SearchBarProps {
value: string
onChange: (value: string) => void
}
const SearchBar: React.FC<SearchBarProps> = ({ value, onChange }) => {
return (
<div className="workflow-palette-search">
<input
type="text"
placeholder="Search blocks..."
value={value}
onChange={(e) => onChange(e.target.value)}
className="workflow-palette-search-input"
/>
{value && (
<button
className="workflow-palette-search-clear"
onClick={() => onChange('')}
>
×
</button>
)}
</div>
)
}
// =============================================================================
// Main Palette Component
// =============================================================================
const WorkflowPalette: React.FC<WorkflowPaletteProps> = ({
editor,
isOpen,
onClose,
}) => {
const [searchQuery, setSearchQuery] = useState('')
const [expandedCategories, setExpandedCategories] = useState<Set<BlockCategory>>(
new Set(['trigger', 'action'])
)
const allBlocks = useMemo(() => getAllBlockDefinitions(), [])
const categories: BlockCategory[] = [
'trigger',
'action',
'condition',
'transformer',
'ai',
'output',
]
const filteredBlocksByCategory = useMemo(() => {
const result: Record<BlockCategory, BlockDefinition[]> = {
trigger: [],
action: [],
condition: [],
transformer: [],
ai: [],
output: [],
}
const query = searchQuery.toLowerCase()
for (const block of allBlocks) {
const matches =
!query ||
block.name.toLowerCase().includes(query) ||
block.description.toLowerCase().includes(query) ||
block.type.toLowerCase().includes(query)
if (matches) {
result[block.category].push(block)
}
}
return result
}, [allBlocks, searchQuery])
const toggleCategory = useCallback((category: BlockCategory) => {
setExpandedCategories((prev) => {
const next = new Set(prev)
if (next.has(category)) {
next.delete(category)
} else {
next.add(category)
}
return next
})
}, [])
const handleBlockClick = useCallback(
(blockType: string) => {
// Set the block type for the tool
setWorkflowBlockType(blockType)
// Switch to the WorkflowBlock tool
editor.setCurrentTool('WorkflowBlock')
},
[editor]
)
if (!isOpen) return null
return (
<div className="workflow-palette">
<div className="workflow-palette-header">
<h3 className="workflow-palette-title">Workflow Blocks</h3>
<button className="workflow-palette-close" onClick={onClose}>
×
</button>
</div>
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<div className="workflow-palette-content">
{categories.map((category) => {
const blocks = filteredBlocksByCategory[category]
if (blocks.length === 0 && searchQuery) return null
return (
<CategorySection
key={category}
category={category}
blocks={blocks}
isExpanded={expandedCategories.has(category) || !!searchQuery}
onToggle={() => toggleCategory(category)}
onBlockClick={handleBlockClick}
/>
)
})}
</div>
<div className="workflow-palette-footer">
<div className="workflow-palette-hint">
Click a block to place it on the canvas
</div>
</div>
</div>
)
}
export default WorkflowPalette

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