Compare commits
12 Commits
d49f7486e2
...
fab58aead0
| Author | SHA1 | Date |
|---|---|---|
|
|
fab58aead0 | |
|
|
b95eb6dc01 | |
|
|
eb7498157f | |
|
|
30608dfdc8 | |
|
|
9f5befc729 | |
|
|
fe0a96ddad | |
|
|
e11eccd34c | |
|
|
c00106e2b7 | |
|
|
037e232b85 | |
|
|
14bf688b60 | |
|
|
af6666bf72 | |
|
|
d4df704c86 |
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ title: 'Open Mapping: Collaborative Route Planning Module'
|
|||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2025-12-04 14:30'
|
||||
updated_date: '2025-12-05 05:35'
|
||||
updated_date: '2025-12-06 06:40'
|
||||
labels:
|
||||
- feature
|
||||
- mapping
|
||||
|
|
@ -129,4 +129,10 @@ Enhanced MapShapeUtil (1326 lines) with:
|
|||
- 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
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ title: Implement proper Automerge CRDT sync for offline-first support
|
|||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2025-12-04 21:06'
|
||||
updated_date: '2025-12-05 03:53'
|
||||
updated_date: '2025-12-05 22:05'
|
||||
labels:
|
||||
- offline-sync
|
||||
- crdt
|
||||
|
|
@ -63,4 +63,24 @@ Solution: Use Automerge's native binary sync protocol with proper CRDT merge sem
|
|||
- 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
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
"@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",
|
||||
|
|
@ -34,6 +35,7 @@
|
|||
"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",
|
||||
|
|
@ -6770,6 +6772,259 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/d3-axis": "*",
|
||||
"@types/d3-brush": "*",
|
||||
"@types/d3-chord": "*",
|
||||
"@types/d3-color": "*",
|
||||
"@types/d3-contour": "*",
|
||||
"@types/d3-delaunay": "*",
|
||||
"@types/d3-dispatch": "*",
|
||||
"@types/d3-drag": "*",
|
||||
"@types/d3-dsv": "*",
|
||||
"@types/d3-ease": "*",
|
||||
"@types/d3-fetch": "*",
|
||||
"@types/d3-force": "*",
|
||||
"@types/d3-format": "*",
|
||||
"@types/d3-geo": "*",
|
||||
"@types/d3-hierarchy": "*",
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-path": "*",
|
||||
"@types/d3-polygon": "*",
|
||||
"@types/d3-quadtree": "*",
|
||||
"@types/d3-random": "*",
|
||||
"@types/d3-scale": "*",
|
||||
"@types/d3-scale-chromatic": "*",
|
||||
"@types/d3-selection": "*",
|
||||
"@types/d3-shape": "*",
|
||||
"@types/d3-time": "*",
|
||||
"@types/d3-time-format": "*",
|
||||
"@types/d3-timer": "*",
|
||||
"@types/d3-transition": "*",
|
||||
"@types/d3-zoom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-axis": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
|
||||
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-brush": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
|
||||
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-chord": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
|
||||
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-contour": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
|
||||
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-dispatch": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
|
||||
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-dsv": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
|
||||
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-fetch": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
|
||||
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-dsv": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-force": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
||||
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-format": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
|
||||
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-hierarchy": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
|
||||
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-polygon": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
|
||||
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-quadtree": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
|
||||
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-random": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
|
||||
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-time-format": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
|
||||
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
|
|
@ -8310,7 +8565,6 @@
|
|||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
|
|
@ -8595,7 +8849,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
||||
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-array": "3",
|
||||
"d3-axis": "3",
|
||||
|
|
@ -8637,7 +8890,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
|
|
@ -8650,7 +8902,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
||||
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8660,7 +8911,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
|
||||
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
|
|
@ -8677,7 +8927,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
|
||||
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-path": "1 - 3"
|
||||
},
|
||||
|
|
@ -8690,7 +8939,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8700,7 +8948,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
|
||||
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.0"
|
||||
},
|
||||
|
|
@ -8713,7 +8960,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"delaunator": "5"
|
||||
},
|
||||
|
|
@ -8726,7 +8972,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8736,7 +8981,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
|
|
@ -8750,7 +8994,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
||||
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"commander": "7",
|
||||
"iconv-lite": "0.6",
|
||||
|
|
@ -8776,7 +9019,6 @@
|
|||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
|
|
@ -8789,7 +9031,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8799,7 +9040,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
|
||||
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-dsv": "1 - 3"
|
||||
},
|
||||
|
|
@ -8812,7 +9052,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-quadtree": "1 - 3",
|
||||
|
|
@ -8827,7 +9066,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8837,7 +9075,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
|
|
@ -8850,7 +9087,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8860,7 +9096,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
|
|
@ -8873,7 +9108,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8883,7 +9117,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
|
||||
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8893,7 +9126,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8903,7 +9135,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
|
||||
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8913,7 +9144,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
|
|
@ -8930,7 +9160,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-interpolate": "1 - 3"
|
||||
|
|
@ -8944,7 +9173,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8954,7 +9182,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
|
|
@ -8967,7 +9194,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
|
|
@ -8980,7 +9206,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
|
|
@ -8993,7 +9218,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -9003,7 +9227,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
|
|
@ -9023,7 +9246,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
|
|
@ -9256,7 +9478,6 @@
|
|||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
|
||||
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
|
|
@ -11170,7 +11391,6 @@
|
|||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -15581,8 +15801,7 @@
|
|||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
|
||||
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
|
||||
"license": "Unlicense",
|
||||
"optional": true
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.53.3",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
"@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,6 +52,7 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -417,21 +417,40 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
|
||||
|
||||
// Merge server data with local data
|
||||
// Automerge handles conflict resolution automatically via CRDT
|
||||
// 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 = {}
|
||||
}
|
||||
// Merge server records - Automerge will handle conflicts
|
||||
|
||||
const localIsEmpty = Object.keys(doc.store).length === 0
|
||||
let addedFromServer = 0
|
||||
let skippedExisting = 0
|
||||
|
||||
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]) {
|
||||
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++
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`📥 Merge strategy: local was ${localIsEmpty ? 'EMPTY' : 'populated'}, added ${addedFromServer} from server, preserved ${skippedExisting} local records`)
|
||||
})
|
||||
|
||||
const finalDoc = handle.doc()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
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.
|
||||
*/
|
||||
const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
|
||||
onAuthenticated,
|
||||
triggeredByEdit = false
|
||||
}) => {
|
||||
const { session } = useAuth();
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
const [showSignUp, setShowSignUp] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(triggeredByEdit);
|
||||
|
||||
// Check if banner was previously dismissed this session
|
||||
useEffect(() => {
|
||||
const dismissed = sessionStorage.getItem('anonymousBannerDismissed');
|
||||
if (dismissed && !triggeredByEdit) {
|
||||
setIsDismissed(true);
|
||||
}
|
||||
}, [triggeredByEdit]);
|
||||
|
||||
// If user is authenticated, don't show banner
|
||||
if (session.authed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If dismissed and not triggered by edit, don't show
|
||||
if (isDismissed && !triggeredByEdit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
sessionStorage.setItem('anonymousBannerDismissed', 'true');
|
||||
setIsDismissed(true);
|
||||
};
|
||||
|
||||
const handleSignUpClick = () => {
|
||||
setShowSignUp(true);
|
||||
};
|
||||
|
||||
const handleSignUpSuccess = () => {
|
||||
setShowSignUp(false);
|
||||
if (onAuthenticated) {
|
||||
onAuthenticated();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignUpCancel = () => {
|
||||
setShowSignUp(false);
|
||||
};
|
||||
|
||||
// Show CryptID modal when sign up is clicked
|
||||
if (showSignUp) {
|
||||
return (
|
||||
<div className="anonymous-banner-modal-overlay">
|
||||
<div className="anonymous-banner-modal">
|
||||
<CryptID
|
||||
onSuccess={handleSignUpSuccess}
|
||||
onCancel={handleSignUpCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`anonymous-viewer-banner ${triggeredByEdit ? 'edit-triggered' : ''} ${isExpanded ? 'expanded' : ''}`}>
|
||||
<div className="banner-content">
|
||||
<div className="banner-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="banner-text">
|
||||
{triggeredByEdit ? (
|
||||
<p className="banner-headline">
|
||||
<strong>Want to edit this board?</strong>
|
||||
</p>
|
||||
) : (
|
||||
<p className="banner-headline">
|
||||
<strong>You're viewing this board anonymously</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isExpanded ? (
|
||||
<div className="banner-details">
|
||||
<p>
|
||||
Sign in by creating a username as your <strong>CryptID</strong> — no password required!
|
||||
</p>
|
||||
<ul className="cryptid-benefits">
|
||||
<li>
|
||||
<span className="benefit-icon">🔒</span>
|
||||
<span>Secured with encrypted keys, right in your browser, by a <a href="https://www.w3.org/TR/WebCryptoAPI/" target="_blank" rel="noopener noreferrer">W3C standard</a> algorithm</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="benefit-icon">💾</span>
|
||||
<span>Your session is stored for offline access, encrypted in browser storage by the same key</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="benefit-icon">📦</span>
|
||||
<span>Full data portability — use your canvas securely any time you like</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="banner-summary">
|
||||
Create a free CryptID to edit this board — no password needed!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="banner-actions">
|
||||
<button
|
||||
className="banner-signup-btn"
|
||||
onClick={handleSignUpClick}
|
||||
>
|
||||
Create CryptID
|
||||
</button>
|
||||
|
||||
{!triggeredByEdit && (
|
||||
<button
|
||||
className="banner-dismiss-btn"
|
||||
onClick={handleDismiss}
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isExpanded && (
|
||||
<button
|
||||
className="banner-expand-btn"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
title="Learn more"
|
||||
>
|
||||
Learn more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{triggeredByEdit && (
|
||||
<div className="banner-edit-notice">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span>This board is in read-only mode for anonymous viewers</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnonymousViewerBanner;
|
||||
|
|
@ -0,0 +1,442 @@
|
|||
/**
|
||||
* NetworkGraphMinimap Component
|
||||
*
|
||||
* A 2D force-directed graph visualization in the bottom-right corner.
|
||||
* Shows:
|
||||
* - User's full network in grey
|
||||
* - Room participants in their presence colors
|
||||
* - Connections as edges between nodes
|
||||
* - Mutual connections as thicker lines
|
||||
*
|
||||
* Features:
|
||||
* - Click node to view profile / connect
|
||||
* - Click edge to edit metadata
|
||||
* - Hover for tooltips
|
||||
* - Expand button to open full 3D view
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import { type GraphNode, type GraphEdge, type TrustLevel, TRUST_LEVEL_COLORS } from '../../lib/networking';
|
||||
import { UserSearchModal } from './UserSearchModal';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface NetworkGraphMinimapProps {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
myConnections: string[];
|
||||
currentUserId?: string;
|
||||
onConnect: (userId: string) => Promise<void>;
|
||||
onDisconnect?: (connectionId: string) => Promise<void>;
|
||||
onNodeClick?: (node: GraphNode) => void;
|
||||
onEdgeClick?: (edge: GraphEdge) => void;
|
||||
onExpandClick?: () => void;
|
||||
width?: number;
|
||||
height?: number;
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
interface SimulationNode extends d3.SimulationNodeDatum, GraphNode {}
|
||||
interface SimulationLink extends d3.SimulationLinkDatum<SimulationNode> {
|
||||
id: string;
|
||||
isMutual: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Styles
|
||||
// =============================================================================
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
position: 'fixed' as const,
|
||||
bottom: '60px',
|
||||
right: '10px',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
alignItems: 'flex-end',
|
||||
gap: '8px',
|
||||
},
|
||||
panel: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.15)',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
panelCollapsed: {
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.02)',
|
||||
},
|
||||
title: {
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#1a1a2e',
|
||||
margin: 0,
|
||||
},
|
||||
headerButtons: {
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
},
|
||||
iconButton: {
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
transition: 'background-color 0.15s',
|
||||
},
|
||||
canvas: {
|
||||
display: 'block',
|
||||
},
|
||||
tooltip: {
|
||||
position: 'absolute' as const,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
color: '#fff',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
pointerEvents: 'none' as const,
|
||||
whiteSpace: 'nowrap' as const,
|
||||
zIndex: 1001,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
marginTop: '-8px',
|
||||
},
|
||||
collapsedIcon: {
|
||||
fontSize: '20px',
|
||||
},
|
||||
stats: {
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
padding: '6px 12px',
|
||||
borderTop: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
},
|
||||
stat: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
},
|
||||
statDot: {
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function NetworkGraphMinimap({
|
||||
nodes,
|
||||
edges,
|
||||
myConnections: _myConnections,
|
||||
currentUserId,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onNodeClick,
|
||||
onEdgeClick,
|
||||
onExpandClick,
|
||||
width = 240,
|
||||
height = 180,
|
||||
isCollapsed = false,
|
||||
onToggleCollapse,
|
||||
}: NetworkGraphMinimapProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const simulationRef = useRef<d3.Simulation<SimulationNode, SimulationLink> | null>(null);
|
||||
|
||||
// Count stats
|
||||
const inRoomCount = nodes.filter(n => n.isInRoom).length;
|
||||
const trustedCount = nodes.filter(n => n.trustLevelTo === 'trusted').length;
|
||||
const connectedCount = nodes.filter(n => n.trustLevelTo === 'connected').length;
|
||||
const unconnectedCount = nodes.filter(n => !n.trustLevelTo && !n.isCurrentUser).length;
|
||||
|
||||
// Initialize and update the D3 simulation
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || isCollapsed || nodes.length === 0) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll('*').remove();
|
||||
|
||||
// Create simulation nodes and links
|
||||
const simNodes: SimulationNode[] = nodes.map(n => ({ ...n }));
|
||||
const nodeMap = new Map(simNodes.map(n => [n.id, n]));
|
||||
|
||||
const simLinks: SimulationLink[] = edges
|
||||
.filter(e => nodeMap.has(e.source) && nodeMap.has(e.target))
|
||||
.map(e => ({
|
||||
source: nodeMap.get(e.source)!,
|
||||
target: nodeMap.get(e.target)!,
|
||||
id: e.id,
|
||||
isMutual: e.isMutual,
|
||||
}));
|
||||
|
||||
// Create the simulation
|
||||
const simulation = d3.forceSimulation<SimulationNode>(simNodes)
|
||||
.force('link', d3.forceLink<SimulationNode, SimulationLink>(simLinks)
|
||||
.id(d => d.id)
|
||||
.distance(40))
|
||||
.force('charge', d3.forceManyBody().strength(-80))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide().radius(12));
|
||||
|
||||
simulationRef.current = simulation;
|
||||
|
||||
// Create container group
|
||||
const g = svg.append('g');
|
||||
|
||||
// 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(0, 0, 0, 0.15)';
|
||||
|
||||
// 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.6)'; // green
|
||||
} else if (level === 'connected') {
|
||||
return 'rgba(234, 179, 8, 0.6)'; // yellow
|
||||
}
|
||||
return 'rgba(0, 0, 0, 0.15)';
|
||||
};
|
||||
|
||||
// Create edges
|
||||
const link = g.append('g')
|
||||
.attr('class', 'links')
|
||||
.selectAll('line')
|
||||
.data(simLinks)
|
||||
.join('line')
|
||||
.attr('stroke', d => getEdgeColor(d))
|
||||
.attr('stroke-width', d => d.isMutual ? 2.5 : 1.5)
|
||||
.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 based on trust level and room status
|
||||
const getNodeColor = (d: SimulationNode) => {
|
||||
if (d.isCurrentUser) {
|
||||
return '#4f46e5'; // Current user is always purple
|
||||
}
|
||||
// If in room, use presence color
|
||||
if (d.isInRoom && d.roomPresenceColor) {
|
||||
return d.roomPresenceColor;
|
||||
}
|
||||
// Otherwise use trust level color
|
||||
if (d.trustLevelTo) {
|
||||
return TRUST_LEVEL_COLORS[d.trustLevelTo];
|
||||
}
|
||||
// Unconnected
|
||||
return TRUST_LEVEL_COLORS.unconnected;
|
||||
};
|
||||
|
||||
// Create nodes
|
||||
const node = g.append('g')
|
||||
.attr('class', 'nodes')
|
||||
.selectAll('circle')
|
||||
.data(simNodes)
|
||||
.join('circle')
|
||||
.attr('r', d => d.isCurrentUser ? 8 : 6)
|
||||
.attr('fill', d => getNodeColor(d))
|
||||
.attr('stroke', d => d.isCurrentUser ? '#fff' : 'none')
|
||||
.attr('stroke-width', d => d.isCurrentUser ? 2 : 0)
|
||||
.style('cursor', 'pointer')
|
||||
.on('mouseenter', (event, d) => {
|
||||
const rect = svgRef.current!.getBoundingClientRect();
|
||||
setTooltip({
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
text: d.displayName || d.username,
|
||||
});
|
||||
})
|
||||
.on('mouseleave', () => {
|
||||
setTooltip(null);
|
||||
})
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
if (onNodeClick) {
|
||||
onNodeClick(d);
|
||||
}
|
||||
})
|
||||
.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!);
|
||||
|
||||
node
|
||||
.attr('cx', d => Math.max(8, Math.min(width - 8, d.x!)))
|
||||
.attr('cy', d => Math.max(8, Math.min(height - 8, d.y!)));
|
||||
});
|
||||
|
||||
return () => {
|
||||
simulation.stop();
|
||||
};
|
||||
}, [nodes, edges, width, height, isCollapsed, onNodeClick, onEdgeClick]);
|
||||
|
||||
// Handle collapsed state click
|
||||
const handleCollapsedClick = useCallback(() => {
|
||||
if (onToggleCollapse) {
|
||||
onToggleCollapse();
|
||||
}
|
||||
}, [onToggleCollapse]);
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div
|
||||
style={{ ...styles.panel, ...styles.panelCollapsed }}
|
||||
onClick={handleCollapsedClick}
|
||||
title="Show network graph"
|
||||
>
|
||||
<span style={styles.collapsedIcon}>🕸️</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.panel}>
|
||||
<div style={styles.header}>
|
||||
<h3 style={styles.title}>Network</h3>
|
||||
<div style={styles.headerButtons}>
|
||||
<button
|
||||
style={styles.iconButton}
|
||||
onClick={() => setIsSearchOpen(true)}
|
||||
title="Find people"
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
{onExpandClick && (
|
||||
<button
|
||||
style={styles.iconButton}
|
||||
onClick={onExpandClick}
|
||||
title="Open full view"
|
||||
>
|
||||
⛶
|
||||
</button>
|
||||
)}
|
||||
{onToggleCollapse && (
|
||||
<button
|
||||
style={styles.iconButton}
|
||||
onClick={onToggleCollapse}
|
||||
title="Collapse"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={styles.canvas}
|
||||
/>
|
||||
|
||||
{tooltip && (
|
||||
<div
|
||||
style={{
|
||||
...styles.tooltip,
|
||||
left: tooltip.x,
|
||||
top: tooltip.y,
|
||||
}}
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={styles.stats}>
|
||||
<div style={styles.stat} title="Users in this room">
|
||||
<div style={{ ...styles.statDot, backgroundColor: '#4f46e5' }} />
|
||||
<span>{inRoomCount}</span>
|
||||
</div>
|
||||
<div style={styles.stat} title="Trusted (edit access)">
|
||||
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.trusted }} />
|
||||
<span>{trustedCount}</span>
|
||||
</div>
|
||||
<div style={styles.stat} title="Connected (view access)">
|
||||
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.connected }} />
|
||||
<span>{connectedCount}</span>
|
||||
</div>
|
||||
<div style={styles.stat} title="Unconnected (no access)">
|
||||
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.unconnected }} />
|
||||
<span>{unconnectedCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UserSearchModal
|
||||
isOpen={isSearchOpen}
|
||||
onClose={() => setIsSearchOpen(false)}
|
||||
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;
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* NetworkGraphPanel Component
|
||||
*
|
||||
* Wrapper that integrates the NetworkGraphMinimap with tldraw.
|
||||
* Extracts room participants from the editor and provides connection actions.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { useEditor, useValue } from 'tldraw';
|
||||
import { NetworkGraphMinimap } from './NetworkGraphMinimap';
|
||||
import { useNetworkGraph } from './useNetworkGraph';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import type { GraphEdge, TrustLevel } from '../../lib/networking';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface NetworkGraphPanelProps {
|
||||
onExpand?: () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
|
||||
const editor = useEditor();
|
||||
const { session } = useAuth();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [selectedEdge, setSelectedEdge] = useState<GraphEdge | null>(null);
|
||||
|
||||
// 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
|
||||
collaborators.forEach((c: any) => {
|
||||
participants.push({
|
||||
id: c.id || c.userId || c.instanceId,
|
||||
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 default trust level
|
||||
const handleConnect = useCallback(async (userId: string) => {
|
||||
await connect(userId);
|
||||
}, [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
|
||||
console.log('Node clicked:', node);
|
||||
}, []);
|
||||
|
||||
// Handle edge click
|
||||
const handleEdgeClick = useCallback((edge: GraphEdge) => {
|
||||
setSelectedEdge(edge);
|
||||
// Could open an edge metadata editor modal
|
||||
console.log('Edge clicked:', edge);
|
||||
}, []);
|
||||
|
||||
// Handle expand to full 3D view
|
||||
const handleExpand = useCallback(() => {
|
||||
if (onExpand) {
|
||||
onExpand();
|
||||
} else {
|
||||
// Default: open in new tab
|
||||
window.open('/graph', '_blank');
|
||||
}
|
||||
}, [onExpand]);
|
||||
|
||||
// Don't render if not authenticated
|
||||
if (!session.authed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<NetworkGraphMinimap
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
myConnections={myConnections}
|
||||
currentUserId={session.username}
|
||||
onConnect={handleConnect}
|
||||
onDisconnect={handleDisconnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
onExpandClick={handleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default NetworkGraphPanel;
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
/**
|
||||
* 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
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
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}>
|
||||
×
|
||||
</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;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Networking Components
|
||||
*
|
||||
* UI components for user networking and social graph visualization.
|
||||
*/
|
||||
|
||||
export { NetworkGraphMinimap } from './NetworkGraphMinimap';
|
||||
export { NetworkGraphPanel } from './NetworkGraphPanel';
|
||||
export { UserSearchModal } from './UserSearchModal';
|
||||
export { useNetworkGraph, useRoomParticipantsFromEditor } from './useNetworkGraph';
|
||||
export type { RoomParticipant, NetworkGraphState, UseNetworkGraphOptions, UseNetworkGraphReturn } from './useNetworkGraph';
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
/**
|
||||
* 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,
|
||||
} 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
|
||||
connect: (userId: string) => 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) => {
|
||||
if (!session.authed || !session.username) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Not authenticated',
|
||||
}));
|
||||
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,
|
||||
})),
|
||||
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 }));
|
||||
|
||||
// Fetch graph, optionally scoped to room
|
||||
let graph: NetworkGraph;
|
||||
if (participantIds.length > 0) {
|
||||
graph = await getRoomNetworkGraph(participantIds);
|
||||
} else {
|
||||
graph = await getMyNetworkGraph();
|
||||
}
|
||||
|
||||
// Enrich nodes with room status and current user flag
|
||||
const enrichedNodes = graph.nodes.map(node => ({
|
||||
...node,
|
||||
isInRoom: participantIds.includes(node.id),
|
||||
roomPresenceColor: participantColorMap.get(node.id),
|
||||
isCurrentUser: node.username === session.username,
|
||||
}));
|
||||
|
||||
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]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchGraph();
|
||||
}, [fetchGraph]);
|
||||
|
||||
// Refresh interval
|
||||
useEffect(() => {
|
||||
if (refreshInterval > 0) {
|
||||
const interval = setInterval(() => fetchGraph(true), refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [refreshInterval, fetchGraph]);
|
||||
|
||||
// Update room status when participants change
|
||||
useEffect(() => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
nodes: prev.nodes.map(node => ({
|
||||
...node,
|
||||
isInRoom: participantIds.includes(node.id),
|
||||
roomPresenceColor: participantColorMap.get(node.id),
|
||||
})),
|
||||
}));
|
||||
}, [participantIds, participantColorMap]);
|
||||
|
||||
// Connect to a user
|
||||
const connect = useCallback(async (userId: string) => {
|
||||
try {
|
||||
await createConnection(userId);
|
||||
// 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;
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from 'react';
|
||||
import { Session, SessionError } from '../lib/auth/types';
|
||||
import { Session, SessionError, PermissionLevel } from '../lib/auth/types';
|
||||
import { AuthService } from '../lib/auth/authService';
|
||||
import { saveSession, clearStoredSession } from '../lib/auth/sessionPersistence';
|
||||
import { WORKER_URL } from '../constants/workerUrl';
|
||||
import * as crypto from '../lib/auth/crypto';
|
||||
|
||||
interface AuthContextType {
|
||||
session: Session;
|
||||
|
|
@ -12,6 +14,12 @@ interface AuthContextType {
|
|||
login: (username: string) => Promise<boolean>;
|
||||
register: (username: string) => Promise<boolean>;
|
||||
logout: () => Promise<void>;
|
||||
/** Fetch and cache the user's permission level for a specific board */
|
||||
fetchBoardPermission: (boardId: string) => Promise<PermissionLevel>;
|
||||
/** Check if user can edit the current board */
|
||||
canEdit: () => boolean;
|
||||
/** Check if user is admin for the current board */
|
||||
isAdmin: () => boolean;
|
||||
}
|
||||
|
||||
const initialSession: Session = {
|
||||
|
|
@ -167,6 +175,82 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
}
|
||||
}, [clearSession]);
|
||||
|
||||
/**
|
||||
* Fetch and cache the user's permission level for a specific board
|
||||
*/
|
||||
const fetchBoardPermission = useCallback(async (boardId: string): Promise<PermissionLevel> => {
|
||||
// Check cache first
|
||||
if (session.boardPermissions?.[boardId]) {
|
||||
return session.boardPermissions[boardId];
|
||||
}
|
||||
|
||||
try {
|
||||
// Get public key for auth header if user is authenticated
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${WORKER_URL}/boards/${boardId}/permission`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch board permission:', response.status);
|
||||
// Default to 'view' for unauthenticated, 'edit' for authenticated
|
||||
return session.authed ? 'edit' : 'view';
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
permission: PermissionLevel;
|
||||
isOwner: boolean;
|
||||
boardExists: boolean;
|
||||
};
|
||||
|
||||
// Cache the permission
|
||||
setSessionState(prev => ({
|
||||
...prev,
|
||||
currentBoardPermission: data.permission,
|
||||
boardPermissions: {
|
||||
...prev.boardPermissions,
|
||||
[boardId]: data.permission,
|
||||
},
|
||||
}));
|
||||
|
||||
return data.permission;
|
||||
} catch (error) {
|
||||
console.error('Error fetching board permission:', error);
|
||||
// Default to 'view' for unauthenticated, 'edit' for authenticated
|
||||
return session.authed ? 'edit' : 'view';
|
||||
}
|
||||
}, [session.authed, session.username, session.boardPermissions]);
|
||||
|
||||
/**
|
||||
* Check if user can edit the current board
|
||||
*/
|
||||
const canEdit = useCallback((): boolean => {
|
||||
const permission = session.currentBoardPermission;
|
||||
if (!permission) {
|
||||
// If no permission set, default based on auth status
|
||||
return session.authed;
|
||||
}
|
||||
return permission === 'edit' || permission === 'admin';
|
||||
}, [session.currentBoardPermission, session.authed]);
|
||||
|
||||
/**
|
||||
* Check if user is admin for the current board
|
||||
*/
|
||||
const isAdmin = useCallback((): boolean => {
|
||||
return session.currentBoardPermission === 'admin';
|
||||
}, [session.currentBoardPermission]);
|
||||
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
|
|
@ -190,8 +274,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
initialize,
|
||||
login,
|
||||
register,
|
||||
logout
|
||||
}), [session, setSession, clearSession, initialize, login, register, logout]);
|
||||
logout,
|
||||
fetchBoardPermission,
|
||||
canEdit,
|
||||
isAdmin,
|
||||
}), [session, setSession, clearSession, initialize, login, register, logout, fetchBoardPermission, canEdit, isAdmin]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,323 @@
|
|||
/* Anonymous Viewer Banner Styles */
|
||||
|
||||
.anonymous-viewer-banner {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10000;
|
||||
|
||||
max-width: 600px;
|
||||
width: calc(100% - 40px);
|
||||
|
||||
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%);
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.3),
|
||||
0 0 40px rgba(139, 92, 246, 0.15);
|
||||
|
||||
animation: slideUp 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.anonymous-viewer-banner.edit-triggered {
|
||||
background: linear-gradient(135deg, #2d1f3d 0%, #3d2d54 100%);
|
||||
border-color: rgba(236, 72, 153, 0.4);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.3),
|
||||
0 0 40px rgba(236, 72, 153, 0.2);
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.edit-triggered .banner-icon {
|
||||
background: linear-gradient(135deg, #ec4899 0%, #a855f7 100%);
|
||||
}
|
||||
|
||||
.banner-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.banner-headline {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
color: #f0f0f0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.banner-headline strong {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.banner-summary {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #a0a0b0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.banner-details {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.banner-details p {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
color: #c0c0d0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.banner-details strong {
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.cryptid-benefits {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.cryptid-benefits li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #a0a0b0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cryptid-benefits li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.benefit-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cryptid-benefits a {
|
||||
color: #8b5cf6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cryptid-benefits a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.banner-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.banner-signup-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.banner-signup-btn:hover {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%);
|
||||
box-shadow: 0 4px 20px rgba(139, 92, 246, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.edit-triggered .banner-signup-btn {
|
||||
background: linear-gradient(135deg, #ec4899 0%, #a855f7 100%);
|
||||
}
|
||||
|
||||
.edit-triggered .banner-signup-btn:hover {
|
||||
background: linear-gradient(135deg, #db2777 0%, #9333ea 100%);
|
||||
box-shadow: 0 4px 20px rgba(236, 72, 153, 0.4);
|
||||
}
|
||||
|
||||
.banner-dismiss-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
color: #808090;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.banner-dismiss-btn:hover {
|
||||
color: #f0f0f0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.banner-expand-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: #8b5cf6;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.banner-expand-btn:hover {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
}
|
||||
|
||||
.banner-edit-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: rgba(236, 72, 153, 0.1);
|
||||
border-top: 1px solid rgba(236, 72, 153, 0.2);
|
||||
border-radius: 0 0 16px 16px;
|
||||
font-size: 13px;
|
||||
color: #f472b6;
|
||||
}
|
||||
|
||||
/* Modal overlay for CryptID sign-up */
|
||||
.anonymous-banner-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.anonymous-banner-modal {
|
||||
max-width: 420px;
|
||||
width: calc(100% - 40px);
|
||||
max-height: calc(100vh - 80px);
|
||||
overflow-y: auto;
|
||||
background: #1e1e2e;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.5);
|
||||
animation: scaleIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode is default, light mode adjustments */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.anonymous-viewer-banner {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-color: rgba(139, 92, 246, 0.2);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.1),
|
||||
0 0 40px rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.banner-headline {
|
||||
color: #2d2d44;
|
||||
}
|
||||
|
||||
.banner-headline strong {
|
||||
color: #1e1e2e;
|
||||
}
|
||||
|
||||
.banner-summary,
|
||||
.banner-details p,
|
||||
.cryptid-benefits li {
|
||||
color: #606080;
|
||||
}
|
||||
|
||||
.banner-dismiss-btn {
|
||||
color: #606080;
|
||||
}
|
||||
|
||||
.banner-dismiss-btn:hover {
|
||||
color: #2d2d44;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.anonymous-viewer-banner {
|
||||
bottom: 10px;
|
||||
max-width: none;
|
||||
width: calc(100% - 20px);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.banner-actions {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.banner-signup-btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,11 @@
|
|||
/**
|
||||
* Permission levels for board access:
|
||||
* - 'view': Read-only access, cannot create/edit/delete shapes
|
||||
* - 'edit': Can create, edit, and delete shapes
|
||||
* - 'admin': Full access including permission management and board settings
|
||||
*/
|
||||
export type PermissionLevel = 'view' | 'edit' | 'admin';
|
||||
|
||||
export interface Session {
|
||||
username: string;
|
||||
authed: boolean;
|
||||
|
|
@ -6,6 +14,10 @@ export interface Session {
|
|||
obsidianVaultPath?: string;
|
||||
obsidianVaultName?: string;
|
||||
error?: string;
|
||||
// Board permission for current board (populated when viewing a board)
|
||||
currentBoardPermission?: PermissionLevel;
|
||||
// Cache of board permissions by board ID
|
||||
boardPermissions?: Record<string, PermissionLevel>;
|
||||
}
|
||||
|
||||
export enum SessionError {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,336 @@
|
|||
/**
|
||||
* Connection Service
|
||||
*
|
||||
* Client-side API for user networking features:
|
||||
* - User search
|
||||
* - Connection management (follow/unfollow)
|
||||
* - Edge metadata (labels, notes, colors)
|
||||
* - Network graph retrieval
|
||||
*/
|
||||
|
||||
import type {
|
||||
UserProfile,
|
||||
UserSearchResult,
|
||||
Connection,
|
||||
ConnectionWithMetadata,
|
||||
EdgeMetadata,
|
||||
NetworkGraph,
|
||||
GraphNode,
|
||||
GraphEdge,
|
||||
TrustLevel,
|
||||
} from './types';
|
||||
|
||||
// =============================================================================
|
||||
// Configuration
|
||||
// =============================================================================
|
||||
|
||||
const API_BASE = '/api/networking';
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: response.statusText })) as { message?: string };
|
||||
throw new Error(errorData.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User Search
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Search for users by username or display name
|
||||
*/
|
||||
export async function searchUsers(
|
||||
query: string,
|
||||
limit: number = 20
|
||||
): Promise<UserSearchResult[]> {
|
||||
const params = new URLSearchParams({ q: query, limit: String(limit) });
|
||||
return fetchJson<UserSearchResult[]>(`${API_BASE}/users/search?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user's public profile
|
||||
*/
|
||||
export async function getUserProfile(userId: string): Promise<UserProfile | null> {
|
||||
try {
|
||||
return await fetchJson<UserProfile>(`${API_BASE}/users/${userId}`);
|
||||
} catch (error) {
|
||||
if ((error as Error).message.includes('404')) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current user's profile
|
||||
*/
|
||||
export async function updateMyProfile(updates: Partial<{
|
||||
displayName: string;
|
||||
bio: string;
|
||||
avatarColor: string;
|
||||
}>): Promise<UserProfile> {
|
||||
return fetchJson<UserProfile>(`${API_BASE}/users/me`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Connection Management
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a connection (follow a user)
|
||||
* @param toUserId - The user to connect to
|
||||
* @param trustLevel - 'connected' (yellow, view) or 'trusted' (green, edit)
|
||||
*/
|
||||
export async function createConnection(
|
||||
toUserId: string,
|
||||
trustLevel: TrustLevel = 'connected'
|
||||
): Promise<Connection> {
|
||||
return fetchJson<Connection>(`${API_BASE}/connections`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ toUserId, trustLevel }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update trust level for an existing connection
|
||||
*/
|
||||
export async function updateTrustLevel(
|
||||
connectionId: string,
|
||||
trustLevel: TrustLevel
|
||||
): Promise<Connection> {
|
||||
return fetchJson<Connection>(`${API_BASE}/connections/${connectionId}/trust`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ trustLevel }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a connection (unfollow a user)
|
||||
*/
|
||||
export async function removeConnection(connectionId: string): Promise<void> {
|
||||
await fetch(`${API_BASE}/connections/${connectionId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific connection by ID
|
||||
*/
|
||||
export async function getConnection(connectionId: string): Promise<ConnectionWithMetadata | null> {
|
||||
try {
|
||||
return await fetchJson<ConnectionWithMetadata>(`${API_BASE}/connections/${connectionId}`);
|
||||
} catch (error) {
|
||||
if ((error as Error).message.includes('404')) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all connections for current user
|
||||
*/
|
||||
export async function getMyConnections(): Promise<ConnectionWithMetadata[]> {
|
||||
return fetchJson<ConnectionWithMetadata[]>(`${API_BASE}/connections`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users who are connected to current user (followers)
|
||||
*/
|
||||
export async function getMyFollowers(): Promise<Connection[]> {
|
||||
return fetchJson<Connection[]>(`${API_BASE}/connections/followers`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user is connected to a specific user
|
||||
*/
|
||||
export async function isConnectedTo(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await fetchJson<{ connected: boolean }>(
|
||||
`${API_BASE}/connections/check/${userId}`
|
||||
);
|
||||
return result.connected;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Edge Metadata
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Update metadata for a connection edge
|
||||
*/
|
||||
export async function updateEdgeMetadata(
|
||||
connectionId: string,
|
||||
metadata: Partial<EdgeMetadata>
|
||||
): Promise<EdgeMetadata> {
|
||||
return fetchJson<EdgeMetadata>(`${API_BASE}/connections/${connectionId}/metadata`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(metadata),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a connection edge
|
||||
*/
|
||||
export async function getEdgeMetadata(connectionId: string): Promise<EdgeMetadata | null> {
|
||||
try {
|
||||
return await fetchJson<EdgeMetadata>(`${API_BASE}/connections/${connectionId}/metadata`);
|
||||
} catch (error) {
|
||||
if ((error as Error).message.includes('404')) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Network Graph
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get the full network graph for current user
|
||||
*/
|
||||
export async function getMyNetworkGraph(): Promise<NetworkGraph> {
|
||||
return fetchJson<NetworkGraph>(`${API_BASE}/graph`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get network graph scoped to room participants
|
||||
* Returns full network in grey, room participants colored
|
||||
*/
|
||||
export async function getRoomNetworkGraph(
|
||||
roomParticipants: string[]
|
||||
): Promise<NetworkGraph> {
|
||||
return fetchJson<NetworkGraph>(`${API_BASE}/graph/room`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ participants: roomParticipants }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mutual connections between current user and another user
|
||||
*/
|
||||
export async function getMutualConnections(userId: string): Promise<UserProfile[]> {
|
||||
return fetchJson<UserProfile[]>(`${API_BASE}/connections/mutual/${userId}`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Graph Building Helpers (Client-side)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Build a GraphNode from a UserProfile and room state
|
||||
*/
|
||||
export function buildGraphNode(
|
||||
profile: UserProfile,
|
||||
options: {
|
||||
isInRoom: boolean;
|
||||
roomPresenceColor?: string;
|
||||
isCurrentUser: boolean;
|
||||
}
|
||||
): GraphNode {
|
||||
return {
|
||||
id: profile.id,
|
||||
username: profile.username,
|
||||
displayName: profile.displayName,
|
||||
avatarColor: profile.avatarColor,
|
||||
isInRoom: options.isInRoom,
|
||||
roomPresenceColor: options.roomPresenceColor,
|
||||
isCurrentUser: options.isCurrentUser,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a GraphEdge from a Connection
|
||||
*/
|
||||
export function buildGraphEdge(
|
||||
connection: ConnectionWithMetadata,
|
||||
currentUserId: string
|
||||
): GraphEdge {
|
||||
const isOnEdge = connection.fromUserId === currentUserId || connection.toUserId === currentUserId;
|
||||
|
||||
return {
|
||||
id: connection.id,
|
||||
source: connection.fromUserId,
|
||||
target: connection.toUserId,
|
||||
trustLevel: connection.trustLevel,
|
||||
effectiveTrustLevel: connection.effectiveTrustLevel,
|
||||
isMutual: connection.isMutual,
|
||||
metadata: isOnEdge ? connection.metadata : undefined,
|
||||
isVisible: true,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Local Storage Cache (for offline/fast loading)
|
||||
// =============================================================================
|
||||
|
||||
const CACHE_KEY = 'network_graph_cache';
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
interface CachedGraph {
|
||||
graph: NetworkGraph;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function getCachedGraph(): NetworkGraph | null {
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
if (!cached) return null;
|
||||
|
||||
const { graph, timestamp }: CachedGraph = JSON.parse(cached);
|
||||
if (Date.now() - timestamp > CACHE_TTL) {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return graph;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setCachedGraph(graph: NetworkGraph): void {
|
||||
try {
|
||||
const cached: CachedGraph = {
|
||||
graph,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cached));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGraphCache(): void {
|
||||
try {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* User Networking Module
|
||||
*
|
||||
* Provides social graph functionality for the canvas:
|
||||
* - User search by username
|
||||
* - One-way connections (following)
|
||||
* - Private edge metadata (labels, notes, colors)
|
||||
* - Network graph visualization
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
UserProfile,
|
||||
UserSearchResult,
|
||||
Connection,
|
||||
ConnectionWithMetadata,
|
||||
EdgeMetadata,
|
||||
GraphNode,
|
||||
GraphEdge,
|
||||
NetworkGraph,
|
||||
RoomNetworkGraph,
|
||||
NetworkEvent,
|
||||
NetworkEventType,
|
||||
TrustLevel,
|
||||
} from './types';
|
||||
|
||||
// Constants
|
||||
export { TRUST_LEVEL_COLORS, TRUST_LEVEL_PERMISSIONS } from './types';
|
||||
|
||||
// Connection Service API
|
||||
export {
|
||||
// User search
|
||||
searchUsers,
|
||||
getUserProfile,
|
||||
updateMyProfile,
|
||||
// Connection management
|
||||
createConnection,
|
||||
updateTrustLevel,
|
||||
removeConnection,
|
||||
getConnection,
|
||||
getMyConnections,
|
||||
getMyFollowers,
|
||||
isConnectedTo,
|
||||
// Edge metadata
|
||||
updateEdgeMetadata,
|
||||
getEdgeMetadata,
|
||||
// Network graph
|
||||
getMyNetworkGraph,
|
||||
getRoomNetworkGraph,
|
||||
getMutualConnections,
|
||||
// Graph building helpers
|
||||
buildGraphNode,
|
||||
buildGraphEdge,
|
||||
// Cache
|
||||
getCachedGraph,
|
||||
setCachedGraph,
|
||||
clearGraphCache,
|
||||
} from './connectionService';
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
/**
|
||||
* User Networking / Social Graph Types
|
||||
*
|
||||
* These types are used for the client-side networking features:
|
||||
* - User search and profiles
|
||||
* - One-way connections (following)
|
||||
* - Edge metadata (private labels/notes on connections)
|
||||
* - Network graph visualization
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// User Profile Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Public user profile for search results and graph nodes
|
||||
*/
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatarColor: string | null;
|
||||
bio: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended profile with connection status (for search results)
|
||||
*/
|
||||
export interface UserSearchResult extends UserProfile {
|
||||
isConnected: boolean; // Am I following them?
|
||||
isConnectedBack: boolean; // Are they following me?
|
||||
mutualConnections: number; // Count of shared connections
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Trust Levels
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Trust levels for connections:
|
||||
* - 'connected': Yellow, grants view permission on shared data
|
||||
* - 'trusted': Green, grants edit permission on shared data
|
||||
*
|
||||
* Unconnected users (grey) have no permissions.
|
||||
* The user themselves has admin access.
|
||||
*/
|
||||
export type TrustLevel = 'connected' | 'trusted';
|
||||
|
||||
/**
|
||||
* Color mapping for trust levels
|
||||
*/
|
||||
export const TRUST_LEVEL_COLORS: Record<TrustLevel | 'unconnected', string> = {
|
||||
unconnected: '#9ca3af', // grey
|
||||
connected: '#eab308', // yellow
|
||||
trusted: '#22c55e', // green
|
||||
};
|
||||
|
||||
/**
|
||||
* Permission mapping for trust levels
|
||||
*/
|
||||
export const TRUST_LEVEL_PERMISSIONS: Record<TrustLevel | 'unconnected', 'none' | 'view' | 'edit'> = {
|
||||
unconnected: 'none',
|
||||
connected: 'view',
|
||||
trusted: 'edit',
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Connection Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A one-way connection from one user to another
|
||||
*/
|
||||
export interface Connection {
|
||||
id: string;
|
||||
fromUserId: string;
|
||||
toUserId: string;
|
||||
trustLevel: TrustLevel;
|
||||
createdAt: string;
|
||||
isMutual: boolean; // True if both users follow each other
|
||||
// The highest trust level between both directions (if mutual)
|
||||
effectiveTrustLevel: TrustLevel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Private metadata for a connection edge
|
||||
* Each party on an edge can have their own metadata
|
||||
*/
|
||||
export interface EdgeMetadata {
|
||||
label: string | null; // Short label (e.g., "Met at ETHDenver")
|
||||
notes: string | null; // Private notes
|
||||
color: string | null; // Custom edge color (hex)
|
||||
strength: number; // 1-10 connection strength
|
||||
}
|
||||
|
||||
/**
|
||||
* Full connection with optional metadata
|
||||
*/
|
||||
export interface ConnectionWithMetadata extends Connection {
|
||||
metadata?: EdgeMetadata;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Graph Types (for visualization)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Node in the network graph
|
||||
*/
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatarColor: string | null;
|
||||
// Connection state (from current user's perspective)
|
||||
trustLevelTo?: TrustLevel; // Trust level I've granted to this user
|
||||
trustLevelFrom?: TrustLevel; // Trust level they've granted to me
|
||||
// Visualization state
|
||||
isInRoom: boolean; // Currently in the same room
|
||||
roomPresenceColor?: string; // Color from room presence (if in room)
|
||||
isCurrentUser: boolean; // Is this the logged-in user
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge in the network graph
|
||||
*/
|
||||
export interface GraphEdge {
|
||||
id: string;
|
||||
source: string; // from user ID
|
||||
target: string; // to user ID
|
||||
trustLevel: TrustLevel; // Trust level of this direction
|
||||
isMutual: boolean; // Both directions exist
|
||||
// The highest trust level between both directions (if mutual)
|
||||
effectiveTrustLevel: TrustLevel | null;
|
||||
// Only included if current user is on this edge
|
||||
metadata?: EdgeMetadata;
|
||||
// Visualization state
|
||||
isVisible: boolean; // Should be rendered (based on privacy)
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete network graph for visualization
|
||||
*/
|
||||
export interface NetworkGraph {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
myConnections: string[]; // User IDs the current user is connected to
|
||||
}
|
||||
|
||||
/**
|
||||
* Room-scoped graph (subset of network that's in current room)
|
||||
*/
|
||||
export interface RoomNetworkGraph extends NetworkGraph {
|
||||
roomId: string;
|
||||
// All room participants (even if not in your network)
|
||||
roomParticipants: string[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Request/Response Types
|
||||
// =============================================================================
|
||||
|
||||
export interface SearchUsersRequest {
|
||||
query: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SearchUsersResponse {
|
||||
users: UserSearchResult[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CreateConnectionRequest {
|
||||
toUserId: string;
|
||||
}
|
||||
|
||||
export interface CreateConnectionResponse {
|
||||
connection: Connection;
|
||||
}
|
||||
|
||||
export interface UpdateEdgeMetadataRequest {
|
||||
connectionId: string;
|
||||
metadata: Partial<EdgeMetadata>;
|
||||
}
|
||||
|
||||
export interface GetNetworkGraphRequest {
|
||||
userId?: string; // If not provided, returns current user's network
|
||||
roomParticipants?: string[]; // If provided, marks which nodes are in room
|
||||
}
|
||||
|
||||
export interface GetNetworkGraphResponse {
|
||||
graph: NetworkGraph;
|
||||
myConnections: string[]; // User IDs I'm connected to
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Real-time Events
|
||||
// =============================================================================
|
||||
|
||||
export type NetworkEventType =
|
||||
| 'connection:created'
|
||||
| 'connection:removed'
|
||||
| 'metadata:updated'
|
||||
| 'user:joined_room'
|
||||
| 'user:left_room';
|
||||
|
||||
export interface NetworkEvent {
|
||||
type: NetworkEventType;
|
||||
payload: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ConnectionCreatedEvent {
|
||||
type: 'connection:created';
|
||||
payload: {
|
||||
connection: Connection;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConnectionRemovedEvent {
|
||||
type: 'connection:removed';
|
||||
payload: {
|
||||
connectionId: string;
|
||||
fromUserId: string;
|
||||
toUserId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MetadataUpdatedEvent {
|
||||
type: 'metadata:updated';
|
||||
payload: {
|
||||
connectionId: string;
|
||||
userId: string; // Who updated their metadata
|
||||
};
|
||||
}
|
||||
|
|
@ -72,7 +72,9 @@ import { GestureTool } from "@/GestureTool"
|
|||
import { CmdK } from "@/CmdK"
|
||||
import { setupMultiPasteHandler } from "@/utils/multiPasteHandler"
|
||||
import { ConnectionStatusIndicator } from "@/components/ConnectionStatusIndicator"
|
||||
|
||||
import AnonymousViewerBanner from "@/components/auth/AnonymousViewerBanner"
|
||||
import { PermissionLevel } from "@/lib/auth/types"
|
||||
import "@/css/anonymous-banner.css"
|
||||
|
||||
import "react-cmdk/dist/cmdk.css"
|
||||
import "@/css/style.css"
|
||||
|
|
@ -272,7 +274,62 @@ export function Board() {
|
|||
}
|
||||
}, [])
|
||||
const roomId = slug || "mycofi33"
|
||||
const { session } = useAuth()
|
||||
const { session, fetchBoardPermission, canEdit } = useAuth()
|
||||
|
||||
// Permission state
|
||||
const [permission, setPermission] = useState<PermissionLevel | null>(null)
|
||||
const [permissionLoading, setPermissionLoading] = useState(true)
|
||||
const [showEditPrompt, setShowEditPrompt] = useState(false)
|
||||
|
||||
// Fetch permission when board loads
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
const loadPermission = async () => {
|
||||
setPermissionLoading(true)
|
||||
try {
|
||||
const perm = await fetchBoardPermission(roomId)
|
||||
if (mounted) {
|
||||
setPermission(perm)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch permission:', error)
|
||||
// Default to view for unauthenticated, edit for authenticated
|
||||
if (mounted) {
|
||||
setPermission(session.authed ? 'edit' : 'view')
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setPermissionLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadPermission()
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [roomId, fetchBoardPermission, session.authed])
|
||||
|
||||
// Check if user can edit (either has edit/admin permission, or is authenticated with default edit access)
|
||||
const isReadOnly = permission === 'view' || (!session.authed && !permission)
|
||||
|
||||
// Handler for when user tries to edit in read-only mode
|
||||
const handleEditAttempt = () => {
|
||||
if (isReadOnly) {
|
||||
setShowEditPrompt(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for successful authentication from banner
|
||||
const handleAuthenticated = () => {
|
||||
setShowEditPrompt(false)
|
||||
// Re-fetch permission after authentication
|
||||
fetchBoardPermission(roomId).then(perm => {
|
||||
setPermission(perm)
|
||||
})
|
||||
}
|
||||
|
||||
// Store roomId in localStorage for VideoChatShapeUtil to access
|
||||
useEffect(() => {
|
||||
|
|
@ -396,6 +453,19 @@ export function Board() {
|
|||
const { connectionState, isNetworkOnline } = storeWithHandle
|
||||
const [editor, setEditor] = useState<Editor | null>(null)
|
||||
|
||||
// Update read-only state when permission changes after editor is mounted
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
|
||||
if (isReadOnly) {
|
||||
editor.updateInstanceState({ isReadonly: true })
|
||||
console.log('🔒 Permission changed: Board is now read-only')
|
||||
} else {
|
||||
editor.updateInstanceState({ isReadonly: false })
|
||||
console.log('🔓 Permission changed: Board is now editable')
|
||||
}
|
||||
}, [editor, isReadOnly])
|
||||
|
||||
useEffect(() => {
|
||||
const value = localStorage.getItem("makereal_settings_2")
|
||||
if (value) {
|
||||
|
|
@ -1114,6 +1184,12 @@ export function Board() {
|
|||
// Note: User presence is configured through the useAutomergeSync hook above
|
||||
// The authenticated username should appear in the people section
|
||||
// MycelialIntelligence is now a permanent UI bar - no shape creation needed
|
||||
|
||||
// Set read-only mode based on permission
|
||||
if (isReadOnly) {
|
||||
editor.updateInstanceState({ isReadonly: true })
|
||||
console.log('🔒 Board is in read-only mode for this user')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CmdK />
|
||||
|
|
@ -1124,6 +1200,13 @@ export function Board() {
|
|||
connectionState={connectionState}
|
||||
isNetworkOnline={isNetworkOnline}
|
||||
/>
|
||||
{/* Anonymous viewer banner - show for unauthenticated users or when edit was attempted */}
|
||||
{(!session.authed || showEditPrompt) && (
|
||||
<AnonymousViewerBanner
|
||||
onAuthenticated={handleAuthenticated}
|
||||
triggeredByEdit={showEditPrompt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AutomergeHandleProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { MycelialIntelligenceBar } from "./MycelialIntelligenceBar"
|
|||
import { CommandPalette } from "./CommandPalette"
|
||||
import { UserSettingsModal } from "./UserSettingsModal"
|
||||
import { GoogleExportBrowser } from "../components/GoogleExportBrowser"
|
||||
import { NetworkGraphPanel } from "../components/networking"
|
||||
import {
|
||||
DefaultKeyboardShortcutsDialog,
|
||||
DefaultKeyboardShortcutsDialogContent,
|
||||
|
|
@ -635,6 +636,7 @@ function CustomInFrontOfCanvas() {
|
|||
<MycelialIntelligenceBar />
|
||||
<FocusLockIndicator />
|
||||
<CommandPalette />
|
||||
<NetworkGraphPanel />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -283,9 +283,11 @@ export class AutomergeDurableObject {
|
|||
serverWebSocket.addEventListener("close", (event) => {
|
||||
console.log(`🔌 AutomergeDurableObject: Client disconnected: ${sessionId}, code: ${event.code}, reason: ${event.reason}`)
|
||||
this.clients.delete(sessionId)
|
||||
// Clean up sync manager state for this peer
|
||||
// Clean up sync manager state for this peer and flush pending saves
|
||||
if (this.syncManager) {
|
||||
this.syncManager.handlePeerDisconnect(sessionId)
|
||||
this.syncManager.handlePeerDisconnect(sessionId).catch((error) => {
|
||||
console.error(`❌ Error handling peer disconnect:`, error)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -262,12 +262,18 @@ export class AutomergeSyncManager {
|
|||
|
||||
/**
|
||||
* Handle peer disconnection
|
||||
* Clean up sync state but don't lose any data
|
||||
* Clean up sync state and flush any pending saves
|
||||
*/
|
||||
handlePeerDisconnect(peerId: string): void {
|
||||
async handlePeerDisconnect(peerId: string): Promise<void> {
|
||||
if (this.peerSyncStates.has(peerId)) {
|
||||
this.peerSyncStates.delete(peerId)
|
||||
console.log(`👋 Peer disconnected: ${peerId}`)
|
||||
|
||||
// If there's a pending save, flush it immediately to prevent data loss
|
||||
if (this.pendingSave) {
|
||||
console.log(`💾 Flushing pending save on peer disconnect`)
|
||||
await this.forceSave()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,581 @@
|
|||
import { Environment, Board, BoardPermission, PermissionLevel, PermissionCheckResult, User } from './types';
|
||||
|
||||
// Generate a UUID v4
|
||||
function generateUUID(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user's effective permission for a board
|
||||
* Priority: explicit permission > board owner (admin) > default permission
|
||||
*/
|
||||
export async function getEffectivePermission(
|
||||
db: D1Database,
|
||||
boardId: string,
|
||||
userId: string | null
|
||||
): Promise<PermissionCheckResult> {
|
||||
// Check if board exists
|
||||
const board = await db.prepare(
|
||||
'SELECT * FROM boards WHERE id = ?'
|
||||
).bind(boardId).first<Board>();
|
||||
|
||||
// Board doesn't exist - treat as new board, anyone authenticated can create
|
||||
if (!board) {
|
||||
// If user is authenticated, they can create the board (will become owner)
|
||||
// If not authenticated, view-only (they'll see empty canvas but can't edit)
|
||||
return {
|
||||
permission: userId ? 'edit' : 'view',
|
||||
isOwner: false,
|
||||
boardExists: false
|
||||
};
|
||||
}
|
||||
|
||||
// If user is not authenticated, return default permission
|
||||
if (!userId) {
|
||||
return {
|
||||
permission: board.default_permission as PermissionLevel,
|
||||
isOwner: false,
|
||||
boardExists: true
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user is the board owner (always admin)
|
||||
if (board.owner_id === userId) {
|
||||
return {
|
||||
permission: 'admin',
|
||||
isOwner: true,
|
||||
boardExists: true
|
||||
};
|
||||
}
|
||||
|
||||
// Check for explicit permission
|
||||
const explicitPerm = await db.prepare(
|
||||
'SELECT * FROM board_permissions WHERE board_id = ? AND user_id = ?'
|
||||
).bind(boardId, userId).first<BoardPermission>();
|
||||
|
||||
if (explicitPerm) {
|
||||
return {
|
||||
permission: explicitPerm.permission,
|
||||
isOwner: false,
|
||||
boardExists: true
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to default permission, but authenticated users get at least 'edit'
|
||||
// (unless board explicitly restricts to view-only)
|
||||
const defaultPerm = board.default_permission as PermissionLevel;
|
||||
|
||||
// For most boards, authenticated users can edit
|
||||
// Board owners can set default_permission to 'view' to restrict this
|
||||
return {
|
||||
permission: defaultPerm === 'view' ? 'view' : 'edit',
|
||||
isOwner: false,
|
||||
boardExists: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a board and assign owner
|
||||
* Called when a new board is first accessed by an authenticated user
|
||||
*/
|
||||
export async function createBoard(
|
||||
db: D1Database,
|
||||
boardId: string,
|
||||
ownerId: string,
|
||||
name?: string
|
||||
): Promise<Board> {
|
||||
const id = boardId;
|
||||
|
||||
await db.prepare(`
|
||||
INSERT INTO boards (id, owner_id, name, default_permission, is_public)
|
||||
VALUES (?, ?, ?, 'edit', 1)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
`).bind(id, ownerId, name || null).run();
|
||||
|
||||
const board = await db.prepare(
|
||||
'SELECT * FROM boards WHERE id = ?'
|
||||
).bind(id).first<Board>();
|
||||
|
||||
if (!board) {
|
||||
throw new Error('Failed to create board');
|
||||
}
|
||||
|
||||
return board;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a board exists, creating it if necessary
|
||||
* Called on first edit by authenticated user
|
||||
*/
|
||||
export async function ensureBoardExists(
|
||||
db: D1Database,
|
||||
boardId: string,
|
||||
userId: string
|
||||
): Promise<Board> {
|
||||
let board = await db.prepare(
|
||||
'SELECT * FROM boards WHERE id = ?'
|
||||
).bind(boardId).first<Board>();
|
||||
|
||||
if (!board) {
|
||||
// Create the board with this user as owner
|
||||
board = await createBoard(db, boardId, userId);
|
||||
}
|
||||
|
||||
return board;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /boards/:boardId/permission
|
||||
* Get current user's permission for a board
|
||||
*/
|
||||
export async function handleGetPermission(
|
||||
boardId: string,
|
||||
request: Request,
|
||||
env: Environment
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
// No database - default to edit for backwards compatibility
|
||||
return new Response(JSON.stringify({
|
||||
permission: 'edit',
|
||||
isOwner: false,
|
||||
boardExists: false,
|
||||
message: 'Permission system not configured'
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Get user ID from public key if provided
|
||||
let userId: string | null = null;
|
||||
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||
|
||||
if (publicKey) {
|
||||
const deviceKey = await db.prepare(
|
||||
'SELECT user_id FROM device_keys WHERE public_key = ?'
|
||||
).bind(publicKey).first<{ user_id: string }>();
|
||||
|
||||
if (deviceKey) {
|
||||
userId = deviceKey.user_id;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getEffectivePermission(db, boardId, userId);
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get permission error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /boards/:boardId/permissions
|
||||
* List all permissions for a board (admin only)
|
||||
*/
|
||||
export async function handleListPermissions(
|
||||
boardId: string,
|
||||
request: Request,
|
||||
env: Environment
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return new Response(JSON.stringify({ error: 'Database not configured' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||
if (!publicKey) {
|
||||
return new Response(JSON.stringify({ error: 'Authentication required' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const deviceKey = await db.prepare(
|
||||
'SELECT user_id FROM device_keys WHERE public_key = ?'
|
||||
).bind(publicKey).first<{ user_id: string }>();
|
||||
|
||||
if (!deviceKey) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid credentials' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const permCheck = await getEffectivePermission(db, boardId, deviceKey.user_id);
|
||||
if (permCheck.permission !== 'admin') {
|
||||
return new Response(JSON.stringify({ error: 'Admin access required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Get all permissions with user info
|
||||
const permissions = await db.prepare(`
|
||||
SELECT bp.*, u.cryptid_username, u.email
|
||||
FROM board_permissions bp
|
||||
JOIN users u ON bp.user_id = u.id
|
||||
WHERE bp.board_id = ?
|
||||
ORDER BY bp.granted_at DESC
|
||||
`).bind(boardId).all<BoardPermission & { cryptid_username: string; email: string }>();
|
||||
|
||||
// Get board info
|
||||
const board = await db.prepare(
|
||||
'SELECT * FROM boards WHERE id = ?'
|
||||
).bind(boardId).first<Board>();
|
||||
|
||||
// Get owner info if exists
|
||||
let owner = null;
|
||||
if (board?.owner_id) {
|
||||
owner = await db.prepare(
|
||||
'SELECT id, cryptid_username, email FROM users WHERE id = ?'
|
||||
).bind(board.owner_id).first<{ id: string; cryptid_username: string; email: string }>();
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
board: board ? {
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
defaultPermission: board.default_permission,
|
||||
isPublic: board.is_public === 1,
|
||||
createdAt: board.created_at
|
||||
} : null,
|
||||
owner,
|
||||
permissions: permissions.results || []
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('List permissions error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /boards/:boardId/permissions
|
||||
* Grant permission to a user (admin only)
|
||||
* Body: { userId, permission, username? }
|
||||
*/
|
||||
export async function handleGrantPermission(
|
||||
boardId: string,
|
||||
request: Request,
|
||||
env: Environment
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return new Response(JSON.stringify({ error: 'Database not configured' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||
if (!publicKey) {
|
||||
return new Response(JSON.stringify({ error: 'Authentication required' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const adminDeviceKey = await db.prepare(
|
||||
'SELECT user_id FROM device_keys WHERE public_key = ?'
|
||||
).bind(publicKey).first<{ user_id: string }>();
|
||||
|
||||
if (!adminDeviceKey) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid credentials' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const permCheck = await getEffectivePermission(db, boardId, adminDeviceKey.user_id);
|
||||
if (permCheck.permission !== 'admin') {
|
||||
return new Response(JSON.stringify({ error: 'Admin access required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json() as {
|
||||
userId?: string;
|
||||
username?: string;
|
||||
permission: PermissionLevel;
|
||||
};
|
||||
|
||||
const { userId, username, permission } = body;
|
||||
|
||||
if (!permission || !['view', 'edit', 'admin'].includes(permission)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid permission level' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Find target user
|
||||
let targetUserId = userId;
|
||||
if (!targetUserId && username) {
|
||||
const user = await db.prepare(
|
||||
'SELECT id FROM users WHERE cryptid_username = ?'
|
||||
).bind(username).first<{ id: string }>();
|
||||
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'User not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
targetUserId = user.id;
|
||||
}
|
||||
|
||||
if (!targetUserId) {
|
||||
return new Response(JSON.stringify({ error: 'userId or username required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure board exists
|
||||
await ensureBoardExists(db, boardId, adminDeviceKey.user_id);
|
||||
|
||||
// Upsert permission
|
||||
await db.prepare(`
|
||||
INSERT INTO board_permissions (id, board_id, user_id, permission, granted_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(board_id, user_id) DO UPDATE SET
|
||||
permission = excluded.permission,
|
||||
granted_by = excluded.granted_by,
|
||||
granted_at = datetime('now')
|
||||
`).bind(generateUUID(), boardId, targetUserId, permission, adminDeviceKey.user_id).run();
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: `Permission '${permission}' granted to user`
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Grant permission error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /boards/:boardId/permissions/:userId
|
||||
* Revoke a user's permission (admin only)
|
||||
*/
|
||||
export async function handleRevokePermission(
|
||||
boardId: string,
|
||||
targetUserId: string,
|
||||
request: Request,
|
||||
env: Environment
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return new Response(JSON.stringify({ error: 'Database not configured' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||
if (!publicKey) {
|
||||
return new Response(JSON.stringify({ error: 'Authentication required' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const adminDeviceKey = await db.prepare(
|
||||
'SELECT user_id FROM device_keys WHERE public_key = ?'
|
||||
).bind(publicKey).first<{ user_id: string }>();
|
||||
|
||||
if (!adminDeviceKey) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid credentials' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const permCheck = await getEffectivePermission(db, boardId, adminDeviceKey.user_id);
|
||||
if (permCheck.permission !== 'admin') {
|
||||
return new Response(JSON.stringify({ error: 'Admin access required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Can't revoke from board owner
|
||||
const board = await db.prepare(
|
||||
'SELECT owner_id FROM boards WHERE id = ?'
|
||||
).bind(boardId).first<{ owner_id: string }>();
|
||||
|
||||
if (board?.owner_id === targetUserId) {
|
||||
return new Response(JSON.stringify({ error: 'Cannot revoke permission from board owner' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Delete permission
|
||||
await db.prepare(
|
||||
'DELETE FROM board_permissions WHERE board_id = ? AND user_id = ?'
|
||||
).bind(boardId, targetUserId).run();
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Permission revoked'
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Revoke permission error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /boards/:boardId
|
||||
* Update board settings (admin only)
|
||||
* Body: { name?, defaultPermission?, isPublic? }
|
||||
*/
|
||||
export async function handleUpdateBoard(
|
||||
boardId: string,
|
||||
request: Request,
|
||||
env: Environment
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return new Response(JSON.stringify({ error: 'Database not configured' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||
if (!publicKey) {
|
||||
return new Response(JSON.stringify({ error: 'Authentication required' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const deviceKey = await db.prepare(
|
||||
'SELECT user_id FROM device_keys WHERE public_key = ?'
|
||||
).bind(publicKey).first<{ user_id: string }>();
|
||||
|
||||
if (!deviceKey) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid credentials' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const permCheck = await getEffectivePermission(db, boardId, deviceKey.user_id);
|
||||
if (permCheck.permission !== 'admin') {
|
||||
return new Response(JSON.stringify({ error: 'Admin access required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json() as {
|
||||
name?: string;
|
||||
defaultPermission?: 'view' | 'edit';
|
||||
isPublic?: boolean;
|
||||
};
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (body.name !== undefined) {
|
||||
updates.push('name = ?');
|
||||
values.push(body.name);
|
||||
}
|
||||
if (body.defaultPermission !== undefined) {
|
||||
if (!['view', 'edit'].includes(body.defaultPermission)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid default permission' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
updates.push('default_permission = ?');
|
||||
values.push(body.defaultPermission);
|
||||
}
|
||||
if (body.isPublic !== undefined) {
|
||||
updates.push('is_public = ?');
|
||||
values.push(body.isPublic ? 1 : 0);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return new Response(JSON.stringify({ error: 'No updates provided' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
updates.push("updated_at = datetime('now')");
|
||||
values.push(boardId);
|
||||
|
||||
await db.prepare(`
|
||||
UPDATE boards SET ${updates.join(', ')} WHERE id = ?
|
||||
`).bind(...values).run();
|
||||
|
||||
const updatedBoard = await db.prepare(
|
||||
'SELECT * FROM boards WHERE id = ?'
|
||||
).bind(boardId).first<Board>();
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
board: updatedBoard ? {
|
||||
id: updatedBoard.id,
|
||||
name: updatedBoard.name,
|
||||
defaultPermission: updatedBoard.default_permission,
|
||||
isPublic: updatedBoard.is_public === 1,
|
||||
updatedAt: updatedBoard.updated_at
|
||||
} : null
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Update board error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,834 @@
|
|||
/**
|
||||
* User Networking API Routes
|
||||
*
|
||||
* Handles:
|
||||
* - User search by username
|
||||
* - Connection management (follow/unfollow)
|
||||
* - Edge metadata (labels, notes, colors)
|
||||
* - Network graph retrieval
|
||||
*/
|
||||
|
||||
import { IRequest } from 'itty-router';
|
||||
import { Environment, UserProfile, UserConnection, ConnectionMetadata, UserNode, GraphEdge, NetworkGraph } from './types';
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function jsonResponse(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function errorResponse(message: string, status = 400): Response {
|
||||
return jsonResponse({ error: message }, status);
|
||||
}
|
||||
|
||||
// Extract user ID from request (from auth header or session)
|
||||
// For now, we'll use a simple header-based approach
|
||||
function getUserIdFromRequest(request: IRequest): string | null {
|
||||
// Check for X-User-Id header (set by client after CryptID auth)
|
||||
const userId = request.headers.get('X-User-Id');
|
||||
return userId;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User Search Routes
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* GET /api/networking/users/search?q=query&limit=20
|
||||
* Search users by username or display name
|
||||
*/
|
||||
export async function searchUsers(request: IRequest, env: Environment): Promise<Response> {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return errorResponse('Database not configured', 500);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const query = url.searchParams.get('q') || '';
|
||||
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100);
|
||||
|
||||
if (!query || query.length < 2) {
|
||||
return errorResponse('Query must be at least 2 characters');
|
||||
}
|
||||
|
||||
const currentUserId = getUserIdFromRequest(request);
|
||||
|
||||
try {
|
||||
// Search users by username or display name
|
||||
const searchPattern = `%${query}%`;
|
||||
const users = await db.prepare(`
|
||||
SELECT
|
||||
u.id,
|
||||
u.cryptid_username as username,
|
||||
COALESCE(p.display_name, u.cryptid_username) as displayName,
|
||||
p.avatar_color as avatarColor,
|
||||
p.bio
|
||||
FROM users u
|
||||
LEFT JOIN user_profiles p ON u.id = p.user_id
|
||||
WHERE (
|
||||
u.cryptid_username LIKE ?1
|
||||
OR p.display_name LIKE ?1
|
||||
)
|
||||
AND (p.is_searchable = 1 OR p.is_searchable IS NULL)
|
||||
LIMIT ?2
|
||||
`).bind(searchPattern, limit).all();
|
||||
|
||||
// If we have a current user, add connection status
|
||||
let results = users.results || [];
|
||||
|
||||
if (currentUserId && results.length > 0) {
|
||||
const userIds = results.map((u: any) => u.id);
|
||||
|
||||
// Get connections from current user
|
||||
const myConnections = await db.prepare(`
|
||||
SELECT to_user_id FROM user_connections WHERE from_user_id = ?
|
||||
`).bind(currentUserId).all();
|
||||
const connectedIds = new Set((myConnections.results || []).map((c: any) => c.to_user_id));
|
||||
|
||||
// Get connections to current user
|
||||
const theirConnections = await db.prepare(`
|
||||
SELECT from_user_id FROM user_connections WHERE to_user_id = ?
|
||||
`).bind(currentUserId).all();
|
||||
const connectedBackIds = new Set((theirConnections.results || []).map((c: any) => c.from_user_id));
|
||||
|
||||
results = results.map((user: any) => ({
|
||||
...user,
|
||||
isConnected: connectedIds.has(user.id),
|
||||
isConnectedBack: connectedBackIds.has(user.id),
|
||||
mutualConnections: 0, // TODO: Calculate mutual connections
|
||||
}));
|
||||
}
|
||||
|
||||
return jsonResponse(results);
|
||||
} catch (error) {
|
||||
console.error('User search error:', error);
|
||||
return errorResponse('Search failed', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/networking/users/:userId
|
||||
* Get a user's public profile
|
||||
*/
|
||||
export async function getUserProfile(request: IRequest, env: Environment): Promise<Response> {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return errorResponse('Database not configured', 500);
|
||||
}
|
||||
|
||||
const { userId } = request.params;
|
||||
|
||||
try {
|
||||
const result = await db.prepare(`
|
||||
SELECT
|
||||
u.id,
|
||||
u.cryptid_username as username,
|
||||
COALESCE(p.display_name, u.cryptid_username) as displayName,
|
||||
p.avatar_color as avatarColor,
|
||||
p.bio
|
||||
FROM users u
|
||||
LEFT JOIN user_profiles p ON u.id = p.user_id
|
||||
WHERE u.id = ?
|
||||
`).bind(userId).first();
|
||||
|
||||
if (!result) {
|
||||
return errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
return jsonResponse(result);
|
||||
} catch (error) {
|
||||
console.error('Get profile error:', error);
|
||||
return errorResponse('Failed to get profile', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/networking/users/me
|
||||
* Update current user's profile
|
||||
*/
|
||||
export async function updateMyProfile(request: IRequest, env: Environment): Promise<Response> {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return errorResponse('Database not configured', 500);
|
||||
}
|
||||
|
||||
const userId = getUserIdFromRequest(request);
|
||||
if (!userId) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json() as {
|
||||
displayName?: string;
|
||||
bio?: string;
|
||||
avatarColor?: string;
|
||||
};
|
||||
|
||||
// Upsert profile
|
||||
await db.prepare(`
|
||||
INSERT INTO user_profiles (user_id, display_name, bio, avatar_color, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, datetime('now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
display_name = COALESCE(?2, display_name),
|
||||
bio = COALESCE(?3, bio),
|
||||
avatar_color = COALESCE(?4, avatar_color),
|
||||
updated_at = datetime('now')
|
||||
`).bind(userId, body.displayName || null, body.bio || null, body.avatarColor || null).run();
|
||||
|
||||
// Return updated profile
|
||||
return getUserProfile({ ...request, params: { userId } } as unknown as IRequest, env);
|
||||
} catch (error) {
|
||||
console.error('Update profile error:', error);
|
||||
return errorResponse('Failed to update profile', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Connection Management Routes
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* POST /api/networking/connections
|
||||
* Create a connection (follow a user)
|
||||
* Body: { toUserId: string, trustLevel?: 'connected' | 'trusted' }
|
||||
*/
|
||||
export async function createConnection(request: IRequest, env: Environment): Promise<Response> {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return errorResponse('Database not configured', 500);
|
||||
}
|
||||
|
||||
const fromUserId = getUserIdFromRequest(request);
|
||||
if (!fromUserId) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json() as { toUserId: string; trustLevel?: 'connected' | 'trusted' };
|
||||
const { toUserId, trustLevel = 'connected' } = body;
|
||||
|
||||
if (!toUserId) {
|
||||
return errorResponse('toUserId is required');
|
||||
}
|
||||
|
||||
if (fromUserId === toUserId) {
|
||||
return errorResponse('Cannot connect to yourself');
|
||||
}
|
||||
|
||||
if (trustLevel !== 'connected' && trustLevel !== 'trusted') {
|
||||
return errorResponse('trustLevel must be "connected" or "trusted"');
|
||||
}
|
||||
|
||||
// Check if target user exists
|
||||
const targetUser = await db.prepare('SELECT id FROM users WHERE id = ?').bind(toUserId).first();
|
||||
if (!targetUser) {
|
||||
return errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
// Check if connection already exists
|
||||
const existing = await db.prepare(`
|
||||
SELECT id FROM user_connections WHERE from_user_id = ? AND to_user_id = ?
|
||||
`).bind(fromUserId, toUserId).first();
|
||||
|
||||
if (existing) {
|
||||
return errorResponse('Already connected');
|
||||
}
|
||||
|
||||
// Create connection
|
||||
const connectionId = generateId();
|
||||
await db.prepare(`
|
||||
INSERT INTO user_connections (id, from_user_id, to_user_id, trust_level)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).bind(connectionId, fromUserId, toUserId, trustLevel).run();
|
||||
|
||||
// Check if mutual and get their trust level
|
||||
const reverseConnection = await db.prepare(`
|
||||
SELECT id, trust_level FROM user_connections WHERE from_user_id = ? AND to_user_id = ?
|
||||
`).bind(toUserId, fromUserId).first() as { id: string; trust_level: string } | null;
|
||||
|
||||
// Calculate effective trust level (highest of both directions)
|
||||
let effectiveTrustLevel = null;
|
||||
if (reverseConnection) {
|
||||
const theirLevel = reverseConnection.trust_level;
|
||||
effectiveTrustLevel = (trustLevel === 'trusted' || theirLevel === 'trusted') ? 'trusted' : 'connected';
|
||||
}
|
||||
|
||||
const connection = {
|
||||
id: connectionId,
|
||||
fromUserId,
|
||||
toUserId,
|
||||
trustLevel,
|
||||
createdAt: new Date().toISOString(),
|
||||
isMutual: !!reverseConnection,
|
||||
effectiveTrustLevel,
|
||||
};
|
||||
|
||||
return jsonResponse(connection, 201);
|
||||
} catch (error) {
|
||||
console.error('Create connection error:', error);
|
||||
return errorResponse('Failed to create connection', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/networking/connections/:connectionId/trust
|
||||
* Update trust level for a connection
|
||||
*/
|
||||
export async function updateConnectionTrust(request: IRequest, env: Environment): Promise<Response> {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return errorResponse('Database not configured', 500);
|
||||
}
|
||||
|
||||
const userId = getUserIdFromRequest(request);
|
||||
if (!userId) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
const { connectionId } = request.params;
|
||||
|
||||
try {
|
||||
// Verify ownership
|
||||
const connection = await db.prepare(`
|
||||
SELECT id, from_user_id, to_user_id FROM user_connections WHERE id = ? AND from_user_id = ?
|
||||
`).bind(connectionId, userId).first() as { id: string; from_user_id: string; to_user_id: string } | null;
|
||||
|
||||
if (!connection) {
|
||||
return errorResponse('Connection not found or not owned by you', 404);
|
||||
}
|
||||
|
||||
const body = await request.json() as { trustLevel: 'connected' | 'trusted' };
|
||||
const { trustLevel } = body;
|
||||
|
||||
if (trustLevel !== 'connected' && trustLevel !== 'trusted') {
|
||||
return errorResponse('trustLevel must be "connected" or "trusted"');
|
||||
}
|
||||
|
||||
// Update trust level
|
||||
await db.prepare(`
|
||||
UPDATE user_connections SET trust_level = ?, updated_at = datetime('now') WHERE id = ?
|
||||
`).bind(trustLevel, connectionId).run();
|
||||
|
||||
// Check if mutual and get their trust level
|
||||
const reverseConnection = await db.prepare(`
|
||||
SELECT trust_level FROM user_connections WHERE from_user_id = ? AND to_user_id = ?
|
||||
`).bind(connection.to_user_id, connection.from_user_id).first() as { trust_level: string } | null;
|
||||
|
||||
let effectiveTrustLevel = null;
|
||||
if (reverseConnection) {
|
||||
const theirLevel = reverseConnection.trust_level;
|
||||
effectiveTrustLevel = (trustLevel === 'trusted' || theirLevel === 'trusted') ? 'trusted' : 'connected';
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
id: connectionId,
|
||||
fromUserId: connection.from_user_id,
|
||||
toUserId: connection.to_user_id,
|
||||
trustLevel,
|
||||
isMutual: !!reverseConnection,
|
||||
effectiveTrustLevel,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update trust level error:', error);
|
||||
return errorResponse('Failed to update trust level', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/networking/connections/:connectionId
|
||||
* Remove a connection (unfollow)
|
||||
*/
|
||||
export async function removeConnection(request: IRequest, env: Environment): Promise<Response> {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return errorResponse('Database not configured', 500);
|
||||
}
|
||||
|
||||
const userId = getUserIdFromRequest(request);
|
||||
if (!userId) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
const { connectionId } = request.params;
|
||||
|
||||
try {
|
||||
// Verify ownership
|
||||
const connection = await db.prepare(`
|
||||
SELECT id FROM user_connections WHERE id = ? AND from_user_id = ?
|
||||
`).bind(connectionId, userId).first();
|
||||
|
||||
if (!connection) {
|
||||
return errorResponse('Connection not found or not owned by you', 404);
|
||||
}
|
||||
|
||||
// Delete connection and its metadata
|
||||
await db.prepare('DELETE FROM connection_metadata WHERE connection_id = ?').bind(connectionId).run();
|
||||
await db.prepare('DELETE FROM user_connections WHERE id = ?').bind(connectionId).run();
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (error) {
|
||||
console.error('Remove connection error:', error);
|
||||
return errorResponse('Failed to remove connection', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/networking/connections
|
||||
* Get current user's connections (people they follow)
|
||||
*/
|
||||
export async function getMyConnections(request: IRequest, env: Environment): Promise<Response> {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return errorResponse('Database not configured', 500);
|
||||
}
|
||||
|
||||
const userId = getUserIdFromRequest(request);
|
||||
if (!userId) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const connections = await db.prepare(`
|
||||
SELECT
|
||||
c.id,
|
||||
c.from_user_id as fromUserId,
|
||||
c.to_user_id as toUserId,
|
||||
c.created_at as createdAt,
|
||||
m.label,
|
||||
m.notes,
|
||||
m.color,
|
||||
m.strength,
|
||||
EXISTS(
|
||||
SELECT 1 FROM user_connections r
|
||||
WHERE r.from_user_id = c.to_user_id AND r.to_user_id = c.from_user_id
|
||||
) as isMutual
|
||||
FROM user_connections c
|
||||
LEFT JOIN connection_metadata m ON c.id = m.connection_id AND m.user_id = ?
|
||||
WHERE c.from_user_id = ?
|
||||
`).bind(userId, userId).all();
|
||||
|
||||
const results = (connections.results || []).map((c: any) => ({
|
||||
id: c.id,
|
||||
fromUserId: c.fromUserId,
|
||||
toUserId: c.toUserId,
|
||||
createdAt: c.createdAt,
|
||||
isMutual: !!c.isMutual,
|
||||
metadata: c.label || c.notes || c.color || c.strength ? {
|
||||
label: c.label,
|
||||
notes: c.notes,
|
||||
color: c.color,
|
||||
strength: c.strength || 5,
|
||||
} : undefined,
|
||||
}));
|
||||
|
||||
return jsonResponse(results);
|
||||
} catch (error) {
|
||||
console.error('Get connections error:', error);
|
||||
return errorResponse('Failed to get connections', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/networking/connections/followers
|
||||
* Get users who follow the current user
|
||||
*/
|
||||
export async function getMyFollowers(request: IRequest, env: Environment): Promise<Response> {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return errorResponse('Database not configured', 500);
|
||||
}
|
||||
|
||||
const userId = getUserIdFromRequest(request);
|
||||
if (!userId) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const connections = await db.prepare(`
|
||||
SELECT
|
||||
c.id,
|
||||
c.from_user_id as fromUserId,
|
||||
c.to_user_id as toUserId,
|
||||
c.created_at as createdAt,
|
||||
EXISTS(
|
||||
SELECT 1 FROM user_connections r
|
||||
WHERE r.from_user_id = c.to_user_id AND r.to_user_id = c.from_user_id
|
||||
) as isMutual
|
||||
FROM user_connections c
|
||||
WHERE c.to_user_id = ?
|
||||
`).bind(userId).all();
|
||||
|
||||
const results = (connections.results || []).map((c: any) => ({
|
||||
id: c.id,
|
||||
fromUserId: c.fromUserId,
|
||||
toUserId: c.toUserId,
|
||||
createdAt: c.createdAt,
|
||||
isMutual: !!c.isMutual,
|
||||
}));
|
||||
|
||||
return jsonResponse(results);
|
||||
} catch (error) {
|
||||
console.error('Get followers error:', error);
|
||||
return errorResponse('Failed to get followers', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/networking/connections/check/:userId
|
||||
* Check if current user is connected to a specific user
|
||||
*/
|
||||
export async function checkConnection(request: IRequest, env: Environment): Promise<Response> {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return errorResponse('Database not configured', 500);
|
||||
}
|
||||
|
||||
const currentUserId = getUserIdFromRequest(request);
|
||||
if (!currentUserId) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
const { userId } = request.params;
|
||||
|
||||
try {
|
||||
const connection = await db.prepare(`
|
||||
SELECT id FROM user_connections WHERE from_user_id = ? AND to_user_id = ?
|
||||
`).bind(currentUserId, userId).first();
|
||||
|
||||
return jsonResponse({ connected: !!connection });
|
||||
} catch (error) {
|
||||
console.error('Check connection error:', error);
|
||||
return errorResponse('Failed to check connection', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Edge Metadata Routes
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* PUT /api/networking/connections/:connectionId/metadata
|
||||
* Update edge metadata for a connection
|
||||
*/
|
||||
export async function updateEdgeMetadata(request: IRequest, env: Environment): Promise<Response> {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return errorResponse('Database not configured', 500);
|
||||
}
|
||||
|
||||
const userId = getUserIdFromRequest(request);
|
||||
if (!userId) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
const { connectionId } = request.params;
|
||||
|
||||
try {
|
||||
// Verify user is on this connection
|
||||
const connection = await db.prepare(`
|
||||
SELECT id, from_user_id, to_user_id FROM user_connections WHERE id = ?
|
||||
`).bind(connectionId).first() as { id: string; from_user_id: string; to_user_id: string } | null;
|
||||
|
||||
if (!connection) {
|
||||
return errorResponse('Connection not found', 404);
|
||||
}
|
||||
|
||||
if (connection.from_user_id !== userId && connection.to_user_id !== userId) {
|
||||
return errorResponse('Not authorized to edit this connection', 403);
|
||||
}
|
||||
|
||||
const body = await request.json() as {
|
||||
label?: string;
|
||||
notes?: string;
|
||||
color?: string;
|
||||
strength?: number;
|
||||
};
|
||||
|
||||
// Validate strength
|
||||
if (body.strength !== undefined && (body.strength < 1 || body.strength > 10)) {
|
||||
return errorResponse('Strength must be between 1 and 10');
|
||||
}
|
||||
|
||||
// Upsert metadata
|
||||
const metadataId = generateId();
|
||||
await db.prepare(`
|
||||
INSERT INTO connection_metadata (id, connection_id, user_id, label, notes, color, strength, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now'))
|
||||
ON CONFLICT(connection_id, user_id) DO UPDATE SET
|
||||
label = COALESCE(?4, label),
|
||||
notes = COALESCE(?5, notes),
|
||||
color = COALESCE(?6, color),
|
||||
strength = COALESCE(?7, strength),
|
||||
updated_at = datetime('now')
|
||||
`).bind(
|
||||
metadataId,
|
||||
connectionId,
|
||||
userId,
|
||||
body.label || null,
|
||||
body.notes || null,
|
||||
body.color || null,
|
||||
body.strength || null
|
||||
).run();
|
||||
|
||||
// Return updated metadata
|
||||
const metadata = await db.prepare(`
|
||||
SELECT label, notes, color, strength FROM connection_metadata
|
||||
WHERE connection_id = ? AND user_id = ?
|
||||
`).bind(connectionId, userId).first();
|
||||
|
||||
return jsonResponse(metadata || { label: null, notes: null, color: null, strength: 5 });
|
||||
} catch (error) {
|
||||
console.error('Update metadata error:', error);
|
||||
return errorResponse('Failed to update metadata', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/networking/connections/:connectionId/metadata
|
||||
* Get edge metadata for a connection
|
||||
*/
|
||||
export async function getEdgeMetadata(request: IRequest, env: Environment): Promise<Response> {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return errorResponse('Database not configured', 500);
|
||||
}
|
||||
|
||||
const userId = getUserIdFromRequest(request);
|
||||
if (!userId) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
const { connectionId } = request.params;
|
||||
|
||||
try {
|
||||
// Verify user is on this connection
|
||||
const connection = await db.prepare(`
|
||||
SELECT id, from_user_id, to_user_id FROM user_connections WHERE id = ?
|
||||
`).bind(connectionId).first() as { id: string; from_user_id: string; to_user_id: string } | null;
|
||||
|
||||
if (!connection) {
|
||||
return errorResponse('Connection not found', 404);
|
||||
}
|
||||
|
||||
if (connection.from_user_id !== userId && connection.to_user_id !== userId) {
|
||||
return errorResponse('Not authorized to view this connection', 403);
|
||||
}
|
||||
|
||||
const metadata = await db.prepare(`
|
||||
SELECT label, notes, color, strength FROM connection_metadata
|
||||
WHERE connection_id = ? AND user_id = ?
|
||||
`).bind(connectionId, userId).first();
|
||||
|
||||
return jsonResponse(metadata || { label: null, notes: null, color: null, strength: 5 });
|
||||
} catch (error) {
|
||||
console.error('Get metadata error:', error);
|
||||
return errorResponse('Failed to get metadata', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Network Graph Routes
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* GET /api/networking/graph
|
||||
* Get the full network graph for current user
|
||||
*/
|
||||
export async function getNetworkGraph(request: IRequest, env: Environment): Promise<Response> {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return errorResponse('Database not configured', 500);
|
||||
}
|
||||
|
||||
const userId = getUserIdFromRequest(request);
|
||||
if (!userId) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all users connected to/from current user
|
||||
const connections = await db.prepare(`
|
||||
SELECT DISTINCT user_id FROM (
|
||||
SELECT to_user_id as user_id FROM user_connections WHERE from_user_id = ?
|
||||
UNION
|
||||
SELECT from_user_id as user_id FROM user_connections WHERE to_user_id = ?
|
||||
)
|
||||
`).bind(userId, userId).all();
|
||||
|
||||
const connectedUserIds = (connections.results || []).map((c: any) => c.user_id);
|
||||
connectedUserIds.push(userId); // Include self
|
||||
|
||||
// Get user profiles for all connected users
|
||||
const placeholders = connectedUserIds.map(() => '?').join(',');
|
||||
const users = await db.prepare(`
|
||||
SELECT
|
||||
u.id,
|
||||
u.cryptid_username as username,
|
||||
COALESCE(p.display_name, u.cryptid_username) as displayName,
|
||||
p.avatar_color as avatarColor,
|
||||
p.bio
|
||||
FROM users u
|
||||
LEFT JOIN user_profiles p ON u.id = p.user_id
|
||||
WHERE u.id IN (${placeholders})
|
||||
`).bind(...connectedUserIds).all();
|
||||
|
||||
// Build nodes
|
||||
const nodes: UserNode[] = (users.results || []).map((u: any) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
displayName: u.displayName,
|
||||
avatarColor: u.avatarColor,
|
||||
bio: u.bio,
|
||||
}));
|
||||
|
||||
// Get all edges between these users
|
||||
const edges = await db.prepare(`
|
||||
SELECT
|
||||
c.id,
|
||||
c.from_user_id as fromUserId,
|
||||
c.to_user_id as toUserId,
|
||||
c.created_at as createdAt,
|
||||
m.label,
|
||||
m.notes,
|
||||
m.color,
|
||||
m.strength,
|
||||
EXISTS(
|
||||
SELECT 1 FROM user_connections r
|
||||
WHERE r.from_user_id = c.to_user_id AND r.to_user_id = c.from_user_id
|
||||
) as isMutual
|
||||
FROM user_connections c
|
||||
LEFT JOIN connection_metadata m ON c.id = m.connection_id AND m.user_id = ?
|
||||
WHERE c.from_user_id IN (${placeholders}) AND c.to_user_id IN (${placeholders})
|
||||
`).bind(userId, ...connectedUserIds, ...connectedUserIds).all();
|
||||
|
||||
const graphEdges: GraphEdge[] = (edges.results || []).map((e: any) => ({
|
||||
id: e.id,
|
||||
source: e.fromUserId,
|
||||
target: e.toUserId,
|
||||
trustLevel: e.trustLevel || 'connected',
|
||||
effectiveTrustLevel: e.isMutual ? (e.trustLevel || 'connected') : null,
|
||||
isMutual: !!e.isMutual,
|
||||
isVisible: true,
|
||||
metadata: (e.fromUserId === userId || e.toUserId === userId) && (e.label || e.notes || e.color || e.strength) ? {
|
||||
label: e.label,
|
||||
notes: e.notes,
|
||||
color: e.color,
|
||||
strength: e.strength || 5,
|
||||
} : undefined,
|
||||
}));
|
||||
|
||||
// Get list of users current user is connected to
|
||||
const myConnections = await db.prepare(`
|
||||
SELECT to_user_id FROM user_connections WHERE from_user_id = ?
|
||||
`).bind(userId).all();
|
||||
|
||||
const graph: NetworkGraph = {
|
||||
nodes,
|
||||
edges: graphEdges,
|
||||
myConnections: (myConnections.results || []).map((c: any) => c.to_user_id),
|
||||
};
|
||||
|
||||
return jsonResponse(graph);
|
||||
} catch (error) {
|
||||
console.error('Get network graph error:', error);
|
||||
return errorResponse('Failed to get network graph', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/networking/graph/room
|
||||
* Get network graph scoped to room participants
|
||||
* Body: { participants: string[] }
|
||||
*/
|
||||
export async function getRoomNetworkGraph(request: IRequest, env: Environment): Promise<Response> {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return errorResponse('Database not configured', 500);
|
||||
}
|
||||
|
||||
const userId = getUserIdFromRequest(request);
|
||||
if (!userId) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json() as { participants: string[] };
|
||||
const { participants } = body;
|
||||
|
||||
if (!participants || !Array.isArray(participants)) {
|
||||
return errorResponse('participants array is required');
|
||||
}
|
||||
|
||||
// First get the full network graph
|
||||
const graphResponse = await getNetworkGraph(request, env);
|
||||
const graph = await graphResponse.json() as NetworkGraph;
|
||||
|
||||
// Mark which nodes are in the room
|
||||
const participantSet = new Set(participants);
|
||||
const nodesWithRoomStatus = graph.nodes.map(node => ({
|
||||
...node,
|
||||
isInRoom: participantSet.has(node.id),
|
||||
}));
|
||||
|
||||
return jsonResponse({
|
||||
...graph,
|
||||
nodes: nodesWithRoomStatus,
|
||||
roomParticipants: participants,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get room network graph error:', error);
|
||||
return errorResponse('Failed to get room network graph', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/networking/connections/mutual/:userId
|
||||
* Get mutual connections between current user and another user
|
||||
*/
|
||||
export async function getMutualConnections(request: IRequest, env: Environment): Promise<Response> {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return errorResponse('Database not configured', 500);
|
||||
}
|
||||
|
||||
const currentUserId = getUserIdFromRequest(request);
|
||||
if (!currentUserId) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
const { userId } = request.params;
|
||||
|
||||
try {
|
||||
// Find users that both current user and target user are connected to
|
||||
const mutuals = await db.prepare(`
|
||||
SELECT
|
||||
u.id,
|
||||
u.cryptid_username as username,
|
||||
COALESCE(p.display_name, u.cryptid_username) as displayName,
|
||||
p.avatar_color as avatarColor,
|
||||
p.bio
|
||||
FROM users u
|
||||
LEFT JOIN user_profiles p ON u.id = p.user_id
|
||||
WHERE u.id IN (
|
||||
SELECT c1.to_user_id
|
||||
FROM user_connections c1
|
||||
INNER JOIN user_connections c2 ON c1.to_user_id = c2.to_user_id
|
||||
WHERE c1.from_user_id = ? AND c2.from_user_id = ?
|
||||
)
|
||||
`).bind(currentUserId, userId).all();
|
||||
|
||||
return jsonResponse(mutuals.results || []);
|
||||
} catch (error) {
|
||||
console.error('Get mutual connections error:', error);
|
||||
return errorResponse('Failed to get mutual connections', 500);
|
||||
}
|
||||
}
|
||||
|
|
@ -45,3 +45,101 @@ CREATE INDEX IF NOT EXISTS idx_device_keys_pubkey ON device_keys(public_key);
|
|||
CREATE INDEX IF NOT EXISTS idx_tokens_token ON verification_tokens(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_email ON verification_tokens(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_expires ON verification_tokens(expires_at);
|
||||
|
||||
-- =============================================================================
|
||||
-- Board Permissions System
|
||||
-- =============================================================================
|
||||
|
||||
-- Board ownership and default permissions
|
||||
-- Each board has an owner (admin) and a default permission level for new visitors
|
||||
CREATE TABLE IF NOT EXISTS boards (
|
||||
id TEXT PRIMARY KEY, -- board slug/room ID (e.g., "mycofi33")
|
||||
owner_id TEXT, -- user ID of creator (NULL for legacy boards)
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
-- Default permission for unauthenticated users: 'view' (read-only) or 'edit' (open)
|
||||
default_permission TEXT DEFAULT 'view' CHECK (default_permission IN ('view', 'edit')),
|
||||
-- Board metadata
|
||||
name TEXT, -- Optional display name
|
||||
description TEXT, -- Optional description
|
||||
is_public INTEGER DEFAULT 1, -- 1 = anyone with link can view, 0 = invite only
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Per-user board permissions
|
||||
-- Overrides the board's default permission for specific users
|
||||
CREATE TABLE IF NOT EXISTS board_permissions (
|
||||
id TEXT PRIMARY KEY,
|
||||
board_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
-- Permission levels: 'view' (read-only), 'edit' (can modify), 'admin' (full access)
|
||||
permission TEXT NOT NULL CHECK (permission IN ('view', 'edit', 'admin')),
|
||||
granted_by TEXT, -- user ID who granted permission (NULL for owner)
|
||||
granted_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE(board_id, user_id)
|
||||
);
|
||||
|
||||
-- Board permission indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_boards_owner ON boards(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_board_perms_board ON board_permissions(board_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_board_perms_user ON board_permissions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_board_perms_board_user ON board_permissions(board_id, user_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- User Networking / Social Graph System
|
||||
-- =============================================================================
|
||||
|
||||
-- User profiles with searchable usernames and display info
|
||||
-- Extends the users table with public profile data
|
||||
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||
user_id TEXT PRIMARY KEY, -- References users.id
|
||||
display_name TEXT, -- Optional display name (defaults to username)
|
||||
bio TEXT, -- Short bio
|
||||
avatar_color TEXT, -- Hex color for avatar
|
||||
is_searchable INTEGER DEFAULT 1, -- 1 = appears in search results
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- User connections (one-way following with trust levels)
|
||||
-- from_user follows to_user (asymmetric)
|
||||
-- Trust levels: 'connected' (yellow, view) or 'trusted' (green, edit)
|
||||
CREATE TABLE IF NOT EXISTS user_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
from_user_id TEXT NOT NULL, -- User who initiated the connection
|
||||
to_user_id TEXT NOT NULL, -- User being connected to
|
||||
trust_level TEXT DEFAULT 'connected' CHECK (trust_level IN ('connected', 'trusted')),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(from_user_id, to_user_id) -- Can only connect once
|
||||
);
|
||||
|
||||
-- Edge metadata (private notes/labels on connections)
|
||||
-- Each user can have their own metadata for a connection edge
|
||||
CREATE TABLE IF NOT EXISTS connection_metadata (
|
||||
id TEXT PRIMARY KEY,
|
||||
connection_id TEXT NOT NULL, -- References user_connections.id
|
||||
user_id TEXT NOT NULL, -- Which party owns this metadata
|
||||
label TEXT, -- Short label (e.g., "Met at ETHDenver")
|
||||
notes TEXT, -- Private notes about the connection
|
||||
color TEXT, -- Custom edge color (hex)
|
||||
strength INTEGER DEFAULT 5 CHECK (strength >= 1 AND strength <= 10), -- 1-10 connection strength
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (connection_id) REFERENCES user_connections(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(connection_id, user_id) -- One metadata entry per user per connection
|
||||
);
|
||||
|
||||
-- User networking indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_searchable ON user_profiles(is_searchable);
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_from ON user_connections(from_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_to ON user_connections(to_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_both ON user_connections(from_user_id, to_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conn_meta_connection ON connection_metadata(connection_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conn_meta_user ON connection_metadata(user_id);
|
||||
|
|
|
|||
140
worker/types.ts
140
worker/types.ts
|
|
@ -48,4 +48,144 @@ export interface VerificationToken {
|
|||
public_key?: string;
|
||||
device_name?: string;
|
||||
user_agent?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Board Permission Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Permission levels for board access:
|
||||
* - 'view': Read-only access, cannot create/edit/delete shapes
|
||||
* - 'edit': Can create, edit, and delete shapes
|
||||
* - 'admin': Full access including permission management and board settings
|
||||
*/
|
||||
export type PermissionLevel = 'view' | 'edit' | 'admin';
|
||||
|
||||
/**
|
||||
* Board record in the database
|
||||
*/
|
||||
export interface Board {
|
||||
id: string; // board slug/room ID
|
||||
owner_id: string | null; // user ID of creator (NULL for legacy boards)
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
default_permission: 'view' | 'edit';
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
is_public: number; // SQLite boolean (0 or 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Board permission record for a specific user
|
||||
*/
|
||||
export interface BoardPermission {
|
||||
id: string;
|
||||
board_id: string;
|
||||
user_id: string;
|
||||
permission: PermissionLevel;
|
||||
granted_by: string | null;
|
||||
granted_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response when checking a user's permission for a board
|
||||
*/
|
||||
export interface PermissionCheckResult {
|
||||
permission: PermissionLevel;
|
||||
isOwner: boolean;
|
||||
boardExists: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User Networking / Social Graph Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* User profile record in the database
|
||||
*/
|
||||
export interface UserProfile {
|
||||
user_id: string;
|
||||
display_name: string | null;
|
||||
bio: string | null;
|
||||
avatar_color: string | null;
|
||||
is_searchable: number; // SQLite boolean (0 or 1)
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust levels for connections:
|
||||
* - 'connected': Yellow, grants view permission on shared data
|
||||
* - 'trusted': Green, grants edit permission on shared data
|
||||
*/
|
||||
export type TrustLevel = 'connected' | 'trusted';
|
||||
|
||||
/**
|
||||
* User connection record (one-way follow with trust level)
|
||||
*/
|
||||
export interface UserConnection {
|
||||
id: string;
|
||||
from_user_id: string;
|
||||
to_user_id: string;
|
||||
trust_level: TrustLevel;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge metadata for a connection (private to each party)
|
||||
*/
|
||||
export interface ConnectionMetadata {
|
||||
id: string;
|
||||
connection_id: string;
|
||||
user_id: string;
|
||||
label: string | null;
|
||||
notes: string | null;
|
||||
color: string | null;
|
||||
strength: number; // 1-10
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined user info for search results and graph nodes
|
||||
*/
|
||||
export interface UserNode {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatarColor: string | null;
|
||||
bio: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Graph edge with connection and optional metadata
|
||||
*/
|
||||
export interface GraphEdge {
|
||||
id: string;
|
||||
fromUserId: string;
|
||||
toUserId: string;
|
||||
trustLevel: TrustLevel;
|
||||
createdAt: string;
|
||||
// Metadata is only included for the requesting user's edges
|
||||
metadata?: {
|
||||
label: string | null;
|
||||
notes: string | null;
|
||||
color: string | null;
|
||||
strength: number;
|
||||
};
|
||||
// Indicates if this is a mutual connection (both follow each other)
|
||||
isMutual: boolean;
|
||||
// The highest trust level between both directions (if mutual)
|
||||
effectiveTrustLevel: TrustLevel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full network graph response
|
||||
*/
|
||||
export interface NetworkGraph {
|
||||
nodes: UserNode[];
|
||||
edges: GraphEdge[];
|
||||
// Current user's connections (for filtering)
|
||||
myConnections: string[]; // User IDs I'm connected to
|
||||
}
|
||||
|
|
@ -1,6 +1,29 @@
|
|||
import { AutoRouter, cors, error, IRequest } from "itty-router"
|
||||
import { handleAssetDownload, handleAssetUpload } from "./assetUploads"
|
||||
import { Environment } from "./types"
|
||||
import {
|
||||
searchUsers,
|
||||
getUserProfile,
|
||||
updateMyProfile,
|
||||
createConnection,
|
||||
updateConnectionTrust,
|
||||
removeConnection,
|
||||
getMyConnections,
|
||||
getMyFollowers,
|
||||
checkConnection,
|
||||
updateEdgeMetadata,
|
||||
getEdgeMetadata,
|
||||
getNetworkGraph,
|
||||
getRoomNetworkGraph,
|
||||
getMutualConnections,
|
||||
} from "./networkingApi"
|
||||
import {
|
||||
handleGetPermission,
|
||||
handleListPermissions,
|
||||
handleGrantPermission,
|
||||
handleRevokePermission,
|
||||
handleUpdateBoard,
|
||||
} from "./boardPermissions"
|
||||
|
||||
// make sure our sync durable objects are made available to cloudflare
|
||||
export { AutomergeDurableObject } from "./AutomergeDurableObject"
|
||||
|
|
@ -81,7 +104,7 @@ const { preflight, corsify } = cors({
|
|||
// If no match found, return * to allow all origins
|
||||
return "*"
|
||||
},
|
||||
allowMethods: ["GET", "POST", "HEAD", "OPTIONS", "UPGRADE"],
|
||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "UPGRADE"],
|
||||
allowHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
|
|
@ -96,6 +119,9 @@ const { preflight, corsify } = cors({
|
|||
"Range",
|
||||
"If-None-Match",
|
||||
"If-Modified-Since",
|
||||
"X-CryptID-PublicKey", // CryptID authentication header
|
||||
"X-User-Id", // User ID header for networking API
|
||||
"X-Api-Key", // API key header for external services
|
||||
"*"
|
||||
],
|
||||
maxAge: 86400,
|
||||
|
|
@ -761,10 +787,10 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|||
.post("/fathom/webhook", async (req) => {
|
||||
try {
|
||||
const body = await req.json()
|
||||
|
||||
|
||||
// Log the webhook for debugging
|
||||
console.log('Fathom webhook received:', JSON.stringify(body, null, 2))
|
||||
|
||||
|
||||
// TODO: Verify webhook signature for security
|
||||
// For now, we'll accept all webhooks. In production, you should:
|
||||
// 1. Get the webhook secret from Fathom
|
||||
|
|
@ -777,17 +803,17 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|||
// headers: { 'Content-Type': 'application/json' }
|
||||
// })
|
||||
// }
|
||||
|
||||
|
||||
// Process the meeting data
|
||||
const meetingData = body as any
|
||||
|
||||
|
||||
// Store meeting data for later retrieval
|
||||
// This could be stored in R2 or Durable Object storage
|
||||
console.log('Processing meeting:', meetingData.meeting_id)
|
||||
|
||||
|
||||
// TODO: Store meeting data in R2 or send to connected clients
|
||||
// For now, just log it
|
||||
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
|
@ -800,6 +826,57 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|||
}
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// User Networking / Social Graph API
|
||||
// =============================================================================
|
||||
|
||||
// User search and profiles
|
||||
.get("/api/networking/users/search", searchUsers)
|
||||
.get("/api/networking/users/me", (req, env) => getUserProfile({ ...req, params: { userId: req.headers.get('X-User-Id') || '' } } as unknown as IRequest, env))
|
||||
.put("/api/networking/users/me", updateMyProfile)
|
||||
.get("/api/networking/users/:userId", getUserProfile)
|
||||
|
||||
// Connection management
|
||||
.post("/api/networking/connections", createConnection)
|
||||
.get("/api/networking/connections", getMyConnections)
|
||||
.get("/api/networking/connections/followers", getMyFollowers)
|
||||
.get("/api/networking/connections/check/:userId", checkConnection)
|
||||
.get("/api/networking/connections/mutual/:userId", getMutualConnections)
|
||||
.put("/api/networking/connections/:connectionId/trust", updateConnectionTrust)
|
||||
.delete("/api/networking/connections/:connectionId", removeConnection)
|
||||
|
||||
// Edge metadata
|
||||
.put("/api/networking/connections/:connectionId/metadata", updateEdgeMetadata)
|
||||
.get("/api/networking/connections/:connectionId/metadata", getEdgeMetadata)
|
||||
|
||||
// Network graph
|
||||
.get("/api/networking/graph", getNetworkGraph)
|
||||
.post("/api/networking/graph/room", getRoomNetworkGraph)
|
||||
|
||||
// =============================================================================
|
||||
// Board Permissions API
|
||||
// =============================================================================
|
||||
|
||||
// Get current user's permission for a board
|
||||
.get("/boards/:boardId/permission", (req, env) =>
|
||||
handleGetPermission(req.params.boardId, req, env))
|
||||
|
||||
// List all permissions for a board (admin only)
|
||||
.get("/boards/:boardId/permissions", (req, env) =>
|
||||
handleListPermissions(req.params.boardId, req, env))
|
||||
|
||||
// Grant permission to a user (admin only)
|
||||
.post("/boards/:boardId/permissions", (req, env) =>
|
||||
handleGrantPermission(req.params.boardId, req, env))
|
||||
|
||||
// Revoke a user's permission (admin only)
|
||||
.delete("/boards/:boardId/permissions/:userId", (req, env) =>
|
||||
handleRevokePermission(req.params.boardId, req.params.userId, req, env))
|
||||
|
||||
// Update board settings (admin only)
|
||||
.patch("/boards/:boardId", (req, env) =>
|
||||
handleUpdateBoard(req.params.boardId, req, env))
|
||||
|
||||
async function backupAllBoards(env: Environment) {
|
||||
try {
|
||||
// List all room files from TLDRAW_BUCKET
|
||||
|
|
|
|||
Loading…
Reference in New Issue