feat: add version history, Resend email, CryptID registration flow

- Switch email service from SendGrid to Resend
- Add multi-step CryptID registration with passwordless explainer
- Add email backup for multi-device account access
- Add version history API endpoints (history, snapshot, diff, revert)
- Create VersionHistoryPanel UI with diff visualization
  - Green highlighting for added shapes
  - Red highlighting for removed shapes
  - Purple highlighting for modified shapes
- Fix network graph connect/trust buttons
- Enhance CryptID dropdown with better integration buttons
- Add Obsidian vault connection modal

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-10 14:21:50 -08:00
parent 2e9c5d583c
commit 9273d741b9
29 changed files with 4360 additions and 1295 deletions

20
package-lock.json generated
View File

@ -81,7 +81,6 @@
},
"engines": {
"node": ">=20.0.0"
<<<<<<< HEAD
}
},
"multmux/packages/cli": {
@ -199,8 +198,6 @@
"utf-8-validate": {
"optional": true
}
=======
>>>>>>> db7bbbf (feat: add invite/share feature with QR code, URL, NFC, and audio connect)
}
},
"node_modules/@ai-sdk/provider": {
@ -14876,6 +14873,15 @@
"node": ">=16.0.0"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
@ -14891,14 +14897,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",

View File

@ -30,6 +30,53 @@ const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) =
});
};
// Detect if we're in share-panel (compact) vs toolbar (full button)
const isCompact = className.includes('share-panel-btn');
if (isCompact) {
// Icon-only version for the top-right share panel
return (
<button
onClick={handleShare}
className={`share-board-button ${className}`}
title="Invite others to this board"
style={{
background: 'none',
border: 'none',
padding: '6px',
cursor: 'pointer',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-1)',
opacity: 0.7,
transition: 'opacity 0.15s, background 0.15s',
pointerEvents: 'all',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1';
e.currentTarget.style.background = 'var(--color-muted-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.7';
e.currentTarget.style.background = 'none';
}}
>
{/* User with plus icon (invite/add person) */}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{/* User outline */}
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
{/* Plus sign */}
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="16" y1="11" x2="22" y2="11" />
</svg>
</button>
);
}
// Full button version for other contexts (toolbar, etc.)
return (
<button
onClick={handleShare}
@ -62,7 +109,13 @@ const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) =
e.currentTarget.style.background = "#3b82f6";
}}
>
<span style={{ fontSize: "12px" }}>Share</span>
{/* User with plus icon (invite/add person) */}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="16" y1="11" x2="22" y2="11" />
</svg>
</button>
);
};

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,12 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useAuth } from '../../context/AuthContext';
import { useEditor, useValue } from 'tldraw';
import CryptID from './CryptID';
import { GoogleDataService, type GoogleService } from '../../lib/google';
import { GoogleExportBrowser } from '../GoogleExportBrowser';
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from '../../lib/fathomApiKey';
import { getMyConnections, createConnection, removeConnection, updateTrustLevel, updateEdgeMetadata } from '../../lib/networking/connectionService';
import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from '../../lib/networking/types';
interface CryptIDDropdownProps {
isDarkMode?: boolean;
@ -13,10 +17,12 @@ interface CryptIDDropdownProps {
* Shows logged-in user with dropdown containing account info and integrations.
*/
const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false }) => {
const { session, logout } = useAuth();
const { session, logout, updateSession } = useAuth();
const [showDropdown, setShowDropdown] = useState(false);
const [showCryptIDModal, setShowCryptIDModal] = useState(false);
const [showGoogleBrowser, setShowGoogleBrowser] = useState(false);
const [showObsidianModal, setShowObsidianModal] = useState(false);
const [obsidianVaultUrl, setObsidianVaultUrl] = useState('');
const [googleConnected, setGoogleConnected] = useState(false);
const [googleLoading, setGoogleLoading] = useState(false);
const [googleCounts, setGoogleCounts] = useState<Record<GoogleService, number>>({
@ -27,6 +33,58 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
});
const dropdownRef = useRef<HTMLDivElement>(null);
// Expanded sections (only integrations and connections now)
const [expandedSection, setExpandedSection] = useState<'none' | 'integrations' | 'connections'>('none');
// Fathom API key state
const [hasFathomApiKey, setHasFathomApiKey] = useState(false);
const [showFathomInput, setShowFathomInput] = useState(false);
const [fathomKeyInput, setFathomKeyInput] = useState('');
// Connections state
const [connections, setConnections] = useState<UserConnectionWithProfile[]>([]);
const [connectionsLoading, setConnectionsLoading] = useState(false);
const [editingConnectionId, setEditingConnectionId] = useState<string | null>(null);
const [editingMetadata, setEditingMetadata] = useState<Partial<EdgeMetadata>>({});
const [savingMetadata, setSavingMetadata] = useState(false);
const [connectingUserId, setConnectingUserId] = useState<string | null>(null);
// Try to get editor (may not exist if outside tldraw context)
let editor: any = null;
let collaborators: any[] = [];
try {
editor = useEditor();
collaborators = useValue('collaborators', () => editor?.getCollaborators() || [], [editor]) || [];
} catch {
// Not inside tldraw context
}
// Canvas users with their connection status
interface CanvasUser {
id: string;
name: string;
color: string;
connectionStatus: 'trusted' | 'connected' | 'unconnected';
connectionId?: string;
}
const canvasUsers: CanvasUser[] = useMemo(() => {
if (!collaborators || collaborators.length === 0) return [];
return collaborators.map((c: any) => {
const userId = c.userId || c.id || c.instanceId;
const connection = connections.find(conn => conn.toUserId === userId);
return {
id: userId,
name: c.userName || c.name || 'Anonymous',
color: c.color || '#888',
connectionStatus: (connection?.trustLevel || 'unconnected') as CanvasUser['connectionStatus'],
connectionId: connection?.id,
};
}).filter((user) => user.name !== session.username);
}, [collaborators, connections, session.username]);
// Check Google connection on mount
useEffect(() => {
const checkGoogleStatus = async () => {
@ -45,17 +103,113 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
checkGoogleStatus();
}, []);
// Close dropdown when clicking outside
// Check Fathom API key
useEffect(() => {
if (session.authed && session.username) {
setHasFathomApiKey(isFathomApiKeyConfigured(session.username));
}
}, [session.authed, session.username]);
// Load connections when authenticated
useEffect(() => {
const loadConnections = async () => {
if (!session.authed || !session.username) return;
setConnectionsLoading(true);
try {
const myConnections = await getMyConnections();
setConnections(myConnections as UserConnectionWithProfile[]);
} catch (error) {
console.error('Failed to load connections:', error);
} finally {
setConnectionsLoading(false);
}
};
loadConnections();
}, [session.authed, session.username]);
// Connection handlers
const handleConnect = async (userId: string, trustLevel: TrustLevel) => {
if (!session.authed || !session.username) return;
setConnectingUserId(userId);
try {
const newConnection = await createConnection(userId, trustLevel);
if (newConnection) {
setConnections(prev => [...prev, newConnection as UserConnectionWithProfile]);
}
} catch (error) {
console.error('Failed to create connection:', error);
} finally {
setConnectingUserId(null);
}
};
const handleDisconnect = async (connectionId: string, userId: string) => {
setConnectingUserId(userId);
try {
await removeConnection(connectionId);
setConnections(prev => prev.filter(c => c.id !== connectionId));
} catch (error) {
console.error('Failed to remove connection:', error);
} finally {
setConnectingUserId(null);
}
};
const handleChangeTrust = async (connectionId: string, userId: string, newLevel: TrustLevel) => {
setConnectingUserId(userId);
try {
const updated = await updateTrustLevel(connectionId, newLevel);
if (updated) {
setConnections(prev => prev.map(c => c.id === connectionId ? updated : c));
}
} catch (error) {
console.error('Failed to update trust level:', error);
} finally {
setConnectingUserId(null);
}
};
const handleSaveMetadata = async (connectionId: string) => {
setSavingMetadata(true);
try {
const updatedMetadata = await updateEdgeMetadata(connectionId, editingMetadata);
if (updatedMetadata) {
setConnections(prev => prev.map(c =>
c.id === connectionId ? { ...c, metadata: updatedMetadata } : c
));
}
setEditingConnectionId(null);
setEditingMetadata({});
} catch (error) {
console.error('Failed to save metadata:', error);
} finally {
setSavingMetadata(false);
}
};
// Close dropdown when clicking outside or pressing ESC
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
setShowDropdown(false);
}
};
if (showDropdown) {
document.addEventListener('mousedown', handleClickOutside);
// Use capture phase to intercept before tldraw
document.addEventListener('keydown', handleKeyDown, true);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [showDropdown]);
const handleGoogleConnect = async () => {
@ -66,6 +220,9 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
setGoogleConnected(true);
const counts = await service.getStoredCounts();
setGoogleCounts(counts);
// After successful connection, open the Google Export Browser
setShowGoogleBrowser(true);
setShowDropdown(false);
} catch (error) {
console.error('Google auth failed:', error);
} finally {
@ -115,10 +272,11 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
}
return (
<div ref={dropdownRef} className="cryptid-dropdown" style={{ position: 'relative' }}>
<div ref={dropdownRef} className="cryptid-dropdown" style={{ position: 'relative', pointerEvents: 'all' }}>
{/* Trigger button */}
<button
onClick={() => setShowDropdown(!showDropdown)}
onPointerDown={(e) => e.stopPropagation()}
className="cryptid-trigger"
style={{
display: 'flex',
@ -131,6 +289,7 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
cursor: 'pointer',
color: 'var(--color-text-1)',
transition: 'background 0.15s',
pointerEvents: 'all',
}}
title={session.authed ? session.username : 'Sign in with CryptID'}
>
@ -179,13 +338,23 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
top: 'calc(100% + 8px)',
right: 0,
minWidth: '260px',
maxHeight: 'calc(100vh - 100px)',
background: 'var(--color-panel)',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
zIndex: 100000,
overflow: 'hidden',
overflowY: 'auto',
overflowX: 'hidden',
pointerEvents: 'all',
}}
onWheel={(e) => {
// Stop wheel events from propagating to canvas when over menu
e.stopPropagation();
}}
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
>
{session.authed ? (
<>
@ -219,6 +388,44 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
</div>
</div>
{/* Quick actions */}
<div style={{ padding: '8px 0', borderBottom: '1px solid var(--color-panel-contrast)' }}>
<a
href="/dashboard/"
onPointerDown={(e) => e.stopPropagation()}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 16px',
fontSize: '13px',
fontWeight: 500,
color: 'var(--color-text)',
textDecoration: 'none',
transition: 'background 0.15s, transform 0.15s',
borderRadius: '6px',
margin: '0 8px',
pointerEvents: 'all',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-muted-2)';
e.currentTarget.style.transform = 'translateX(2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.transform = 'translateX(0)';
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="#f59e0b" stroke="#f59e0b" strokeWidth="1">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
My Saved Boards
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: 'auto', opacity: 0.5 }}>
<polyline points="9 18 15 12 9 6"/>
</svg>
</a>
</div>
{/* Integrations section */}
<div style={{ padding: '8px 0' }}>
<div style={{
@ -275,31 +482,36 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
setShowGoogleBrowser(true);
setShowDropdown(false);
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
flex: 1,
padding: '6px 12px',
padding: '8px 14px',
fontSize: '12px',
fontWeight: 500,
fontWeight: 600,
borderRadius: '6px',
border: 'none',
backgroundColor: '#4285F4',
background: 'linear-gradient(135deg, #4285F4, #34A853)',
color: 'white',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(66, 133, 244, 0.3)',
pointerEvents: 'all',
}}
>
Browse Data
</button>
<button
onClick={handleGoogleDisconnect}
onPointerDown={(e) => e.stopPropagation()}
style={{
padding: '6px 12px',
padding: '8px 14px',
fontSize: '12px',
fontWeight: 500,
borderRadius: '6px',
border: '1px solid var(--color-panel-contrast)',
backgroundColor: 'transparent',
color: 'var(--color-text-3)',
backgroundColor: 'var(--color-muted-2)',
color: 'var(--color-text)',
cursor: 'pointer',
pointerEvents: 'all',
}}
>
Disconnect
@ -308,18 +520,30 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
) : (
<button
onClick={handleGoogleConnect}
onPointerDown={(e) => e.stopPropagation()}
disabled={googleLoading}
style={{
flex: 1,
padding: '6px 12px',
padding: '8px 16px',
fontSize: '12px',
fontWeight: 500,
fontWeight: 600,
borderRadius: '6px',
border: '1px solid var(--color-panel-contrast)',
backgroundColor: 'transparent',
color: 'var(--color-text)',
border: 'none',
background: 'linear-gradient(135deg, #4285F4, #34A853)',
color: 'white',
cursor: googleLoading ? 'wait' : 'pointer',
opacity: googleLoading ? 0.7 : 1,
transition: 'transform 0.15s, box-shadow 0.15s',
boxShadow: '0 2px 8px rgba(66, 133, 244, 0.3)',
pointerEvents: 'all',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(66, 133, 244, 0.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(66, 133, 244, 0.3)';
}}
>
{googleLoading ? 'Connecting...' : 'Connect Google'}
@ -327,6 +551,250 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
)}
</div>
</div>
{/* Obsidian Vault */}
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-panel-contrast)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
<div style={{
width: '28px',
height: '28px',
borderRadius: '6px',
background: '#7c3aed',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
}}>
📁
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text)' }}>
Obsidian Vault
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>
{session.obsidianVaultName || 'Not connected'}
</div>
</div>
{session.obsidianVaultName && (
<span style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: '#22c55e',
}} />
)}
</div>
<button
onClick={() => {
setShowObsidianModal(true);
setShowDropdown(false);
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
width: '100%',
padding: '8px 16px',
fontSize: '12px',
fontWeight: 600,
borderRadius: '6px',
border: 'none',
background: session.obsidianVaultName
? 'var(--color-muted-2)'
: 'linear-gradient(135deg, #7c3aed 0%, #a855f7 100%)',
color: session.obsidianVaultName ? 'var(--color-text)' : 'white',
cursor: 'pointer',
boxShadow: session.obsidianVaultName ? 'none' : '0 2px 8px rgba(124, 58, 237, 0.3)',
pointerEvents: 'all',
transition: 'transform 0.15s, box-shadow 0.15s',
}}
onMouseEnter={(e) => {
if (!session.obsidianVaultName) {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(124, 58, 237, 0.4)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = session.obsidianVaultName ? 'none' : '0 2px 8px rgba(124, 58, 237, 0.3)';
}}
>
{session.obsidianVaultName ? 'Change Vault' : 'Connect Vault'}
</button>
</div>
{/* Fathom Meetings */}
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-panel-contrast)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
<div style={{
width: '28px',
height: '28px',
borderRadius: '6px',
background: '#ef4444',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
}}>
🎥
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text)' }}>
Fathom Meetings
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>
{hasFathomApiKey ? 'Connected' : 'Not connected'}
</div>
</div>
{hasFathomApiKey && (
<span style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: '#22c55e',
}} />
)}
</div>
{showFathomInput ? (
<div>
<input
type="password"
value={fathomKeyInput}
onChange={(e) => setFathomKeyInput(e.target.value)}
placeholder="Enter Fathom API key..."
style={{
width: '100%',
padding: '6px 8px',
fontSize: '11px',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '4px',
marginBottom: '6px',
background: 'var(--color-panel)',
color: 'var(--color-text)',
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && fathomKeyInput.trim()) {
saveFathomApiKey(fathomKeyInput.trim(), session.username);
setHasFathomApiKey(true);
setShowFathomInput(false);
setFathomKeyInput('');
} else if (e.key === 'Escape') {
setShowFathomInput(false);
setFathomKeyInput('');
}
}}
autoFocus
/>
<div style={{ display: 'flex', gap: '6px' }}>
<button
onClick={() => {
if (fathomKeyInput.trim()) {
saveFathomApiKey(fathomKeyInput.trim(), session.username);
setHasFathomApiKey(true);
setShowFathomInput(false);
setFathomKeyInput('');
}
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
flex: 1,
padding: '6px 10px',
fontSize: '11px',
fontWeight: 600,
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
pointerEvents: 'all',
}}
>
Save
</button>
<button
onClick={() => {
setShowFathomInput(false);
setFathomKeyInput('');
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
flex: 1,
padding: '6px 10px',
fontSize: '11px',
fontWeight: 500,
backgroundColor: 'var(--color-muted-2)',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
color: 'var(--color-text)',
pointerEvents: 'all',
}}
>
Cancel
</button>
</div>
</div>
) : (
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => {
setShowFathomInput(true);
const currentKey = getFathomApiKey(session.username);
if (currentKey) setFathomKeyInput(currentKey);
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
flex: 1,
padding: '8px 16px',
fontSize: '12px',
fontWeight: 600,
borderRadius: '6px',
border: 'none',
background: hasFathomApiKey
? 'var(--color-muted-2)'
: 'linear-gradient(135deg, #ef4444 0%, #f97316 100%)',
color: hasFathomApiKey ? 'var(--color-text)' : 'white',
cursor: 'pointer',
boxShadow: hasFathomApiKey ? 'none' : '0 2px 8px rgba(239, 68, 68, 0.3)',
pointerEvents: 'all',
transition: 'transform 0.15s, box-shadow 0.15s',
}}
onMouseEnter={(e) => {
if (!hasFathomApiKey) {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(239, 68, 68, 0.4)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = hasFathomApiKey ? 'none' : '0 2px 8px rgba(239, 68, 68, 0.3)';
}}
>
{hasFathomApiKey ? 'Change Key' : 'Add API Key'}
</button>
{hasFathomApiKey && (
<button
onClick={() => {
removeFathomApiKey(session.username);
setHasFathomApiKey(false);
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
padding: '8px 14px',
fontSize: '12px',
fontWeight: 500,
borderRadius: '6px',
backgroundColor: '#fee2e2',
color: '#dc2626',
border: 'none',
cursor: 'pointer',
pointerEvents: 'all',
}}
>
Disconnect
</button>
)}
</div>
)}
</div>
</div>
{/* Sign out */}
@ -410,6 +878,240 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
isDarkMode={isDarkMode}
/>
)}
{/* Obsidian Vault Connection Modal */}
{showObsidianModal && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100001,
}}
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowObsidianModal(false);
}
}}
>
<div
style={{
backgroundColor: 'var(--color-panel)',
borderRadius: '16px',
padding: '24px',
width: '400px',
maxWidth: '90vw',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '20px' }}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '10px',
background: 'linear-gradient(135deg, #7c3aed 0%, #a855f7 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
}}>
📁
</div>
<div>
<h3 style={{ margin: 0, fontSize: '18px', fontWeight: 600, color: 'var(--color-text)' }}>
Connect Obsidian Vault
</h3>
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-3)' }}>
Import your notes to the canvas
</p>
</div>
</div>
{session.obsidianVaultName && (
<div style={{
padding: '12px',
backgroundColor: 'rgba(124, 58, 237, 0.1)',
borderRadius: '8px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '10px',
}}>
<span style={{ color: '#22c55e', fontSize: '16px' }}></span>
<div>
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text)' }}>
Currently connected
</div>
<div style={{ fontSize: '12px', color: 'var(--color-text-3)' }}>
{session.obsidianVaultName}
</div>
</div>
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* Option 1: Select Local Folder */}
<button
onClick={async () => {
try {
// Use File System Access API
const dirHandle = await (window as any).showDirectoryPicker({
mode: 'read',
});
const vaultName = dirHandle.name;
updateSession({
obsidianVaultPath: 'folder-selected',
obsidianVaultName: vaultName,
});
setShowObsidianModal(false);
// Dispatch event to open browser on canvas
window.dispatchEvent(new CustomEvent('open-obsidian-browser'));
} catch (err: any) {
if (err.name !== 'AbortError') {
console.error('Failed to select folder:', err);
}
}
}}
style={{
width: '100%',
padding: '14px 16px',
fontSize: '14px',
fontWeight: 600,
borderRadius: '10px',
border: 'none',
background: 'linear-gradient(135deg, #7c3aed 0%, #a855f7 100%)',
color: 'white',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '10px',
boxShadow: '0 4px 12px rgba(124, 58, 237, 0.3)',
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
Select Local Folder
</button>
{/* Option 2: Enter URL */}
<div style={{ position: 'relative' }}>
<input
type="url"
placeholder="Or enter Quartz/Obsidian Publish URL..."
value={obsidianVaultUrl}
onChange={(e) => setObsidianVaultUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && obsidianVaultUrl.trim()) {
updateSession({
obsidianVaultPath: obsidianVaultUrl.trim(),
obsidianVaultName: new URL(obsidianVaultUrl.trim()).hostname,
});
setShowObsidianModal(false);
setObsidianVaultUrl('');
window.dispatchEvent(new CustomEvent('open-obsidian-browser'));
}
}}
style={{
width: '100%',
padding: '12px 14px',
fontSize: '13px',
borderRadius: '8px',
border: '1px solid var(--color-panel-contrast)',
background: 'var(--color-panel)',
color: 'var(--color-text)',
outline: 'none',
}}
/>
{obsidianVaultUrl && (
<button
onClick={() => {
if (obsidianVaultUrl.trim()) {
try {
updateSession({
obsidianVaultPath: obsidianVaultUrl.trim(),
obsidianVaultName: new URL(obsidianVaultUrl.trim()).hostname,
});
setShowObsidianModal(false);
setObsidianVaultUrl('');
window.dispatchEvent(new CustomEvent('open-obsidian-browser'));
} catch (err) {
console.error('Invalid URL:', err);
}
}
}}
style={{
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
padding: '6px 12px',
fontSize: '12px',
fontWeight: 600,
borderRadius: '6px',
border: 'none',
background: '#7c3aed',
color: 'white',
cursor: 'pointer',
}}
>
Connect
</button>
)}
</div>
{session.obsidianVaultName && (
<button
onClick={() => {
updateSession({
obsidianVaultPath: undefined,
obsidianVaultName: undefined,
});
}}
style={{
width: '100%',
padding: '10px 16px',
fontSize: '13px',
fontWeight: 500,
borderRadius: '8px',
border: 'none',
backgroundColor: '#fee2e2',
color: '#dc2626',
cursor: 'pointer',
}}
>
Disconnect Vault
</button>
)}
</div>
<button
onClick={() => setShowObsidianModal(false)}
style={{
width: '100%',
marginTop: '16px',
padding: '10px 16px',
fontSize: '13px',
fontWeight: 500,
borderRadius: '8px',
border: 'none',
backgroundColor: 'var(--color-muted-2)',
color: 'var(--color-text)',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>
)}
</div>
);
};

View File

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

View File

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

View File

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

View File

@ -29,7 +29,7 @@ interface NetworkGraphMinimapProps {
edges: GraphEdge[];
myConnections: string[];
currentUserId?: string;
onConnect: (userId: string) => Promise<void>;
onConnect: (userId: string, trustLevel?: TrustLevel) => Promise<void>;
onDisconnect?: (connectionId: string) => Promise<void>;
onNodeClick?: (node: GraphNode) => void;
onEdgeClick?: (edge: GraphEdge) => void;
@ -38,6 +38,7 @@ interface NetworkGraphMinimapProps {
height?: number;
isCollapsed?: boolean;
onToggleCollapse?: () => void;
isDarkMode?: boolean;
}
interface SimulationNode extends d3.SimulationNodeDatum, GraphNode {}
@ -47,10 +48,10 @@ interface SimulationLink extends d3.SimulationLinkDatum<SimulationNode> {
}
// =============================================================================
// Styles
// Styles - Theme-aware functions
// =============================================================================
const styles = {
const getStyles = (isDarkMode: boolean) => ({
container: {
position: 'fixed' as const,
bottom: '60px',
@ -62,12 +63,12 @@ const styles = {
gap: '8px',
},
panel: {
backgroundColor: 'rgba(20, 20, 25, 0.95)',
backgroundColor: isDarkMode ? 'rgba(20, 20, 25, 0.95)' : 'rgba(255, 255, 255, 0.98)',
borderRadius: '12px',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.4)',
boxShadow: isDarkMode ? '0 4px 20px rgba(0, 0, 0, 0.4)' : '0 4px 20px rgba(0, 0, 0, 0.15)',
overflow: 'hidden',
transition: 'all 0.2s ease',
border: '1px solid rgba(255, 255, 255, 0.1)',
border: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
},
panelCollapsed: {
width: '48px',
@ -82,13 +83,13 @@ const styles = {
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
backgroundColor: 'rgba(255, 255, 255, 0.03)',
borderBottom: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
backgroundColor: isDarkMode ? 'rgba(255, 255, 255, 0.03)' : 'rgba(0, 0, 0, 0.02)',
},
title: {
fontSize: '12px',
fontWeight: 600,
color: '#e0e0e0',
color: isDarkMode ? '#e0e0e0' : '#374151',
margin: 0,
},
headerButtons: {
@ -106,16 +107,17 @@ const styles = {
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
color: '#a0a0a0',
color: isDarkMode ? '#a0a0a0' : '#6b7280',
transition: 'background-color 0.15s, color 0.15s',
},
canvas: {
display: 'block',
backgroundColor: isDarkMode ? 'transparent' : 'rgba(249, 250, 251, 0.5)',
},
tooltip: {
position: 'absolute' as const,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
color: '#fff',
backgroundColor: isDarkMode ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.95)',
color: isDarkMode ? '#fff' : '#1f2937',
padding: '6px 10px',
borderRadius: '6px',
fontSize: '12px',
@ -124,6 +126,8 @@ const styles = {
zIndex: 1001,
transform: 'translate(-50%, -100%)',
marginTop: '-8px',
boxShadow: isDarkMode ? 'none' : '0 2px 8px rgba(0, 0, 0, 0.15)',
border: isDarkMode ? 'none' : '1px solid rgba(0, 0, 0, 0.1)',
},
collapsedIcon: {
fontSize: '20px',
@ -132,10 +136,10 @@ const styles = {
display: 'flex',
gap: '12px',
padding: '6px 12px',
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
borderTop: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.08)',
fontSize: '11px',
color: '#888',
backgroundColor: 'rgba(0, 0, 0, 0.2)',
color: isDarkMode ? '#888' : '#6b7280',
backgroundColor: isDarkMode ? 'rgba(0, 0, 0, 0.2)' : 'rgba(0, 0, 0, 0.02)',
},
stat: {
display: 'flex',
@ -147,7 +151,7 @@ const styles = {
height: '8px',
borderRadius: '50%',
},
};
});
// =============================================================================
// Component
@ -167,6 +171,7 @@ export function NetworkGraphMinimap({
height = 180,
isCollapsed = false,
onToggleCollapse,
isDarkMode = false,
}: NetworkGraphMinimapProps) {
const svgRef = useRef<SVGSVGElement>(null);
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
@ -175,6 +180,9 @@ export function NetworkGraphMinimap({
const [isConnecting, setIsConnecting] = useState(false);
const simulationRef = useRef<d3.Simulation<SimulationNode, SimulationLink> | null>(null);
// Get theme-aware styles
const styles = React.useMemo(() => getStyles(isDarkMode), [isDarkMode]);
// Count stats
const inRoomCount = nodes.filter(n => n.isInRoom).length;
const anonymousCount = nodes.filter(n => n.isAnonymous).length;
@ -202,14 +210,18 @@ export function NetworkGraphMinimap({
isMutual: e.isMutual,
}));
// Create the simulation
// Create the simulation with faster decay for stabilization
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));
.force('collision', d3.forceCollide().radius(12))
// Speed up stabilization: higher decay = faster settling
.alphaDecay(0.05)
// Lower alpha min threshold for stopping
.alphaMin(0.01);
simulationRef.current = simulation;
@ -404,6 +416,12 @@ export function NetworkGraphMinimap({
.attr('cy', d => Math.max(8, Math.min(height - 8, d.y!)));
});
// Stop simulation when it stabilizes (alpha reaches alphaMin)
simulation.on('end', () => {
// Simulation has stabilized, nodes will stay in place unless dragged
simulation.stop();
});
return () => {
simulation.stop();
};
@ -573,7 +591,9 @@ export function NetworkGraphMinimap({
onClick={async () => {
setIsConnecting(true);
try {
await onConnect(selectedNode.node.id);
// Use username for API call (CryptID username), not tldraw session id
const userId = selectedNode.node.username || selectedNode.node.id;
await onConnect(userId, 'connected');
} catch (err) {
console.error('Failed to connect:', err);
}
@ -597,9 +617,10 @@ export function NetworkGraphMinimap({
onClick={async () => {
setIsConnecting(true);
try {
// Connect with trusted level
await onConnect(selectedNode.node.id);
// Then upgrade - would need separate call
// Use username for API call (CryptID username), not tldraw session id
// Connect with trusted level directly
const userId = selectedNode.node.username || selectedNode.node.id;
await onConnect(userId, 'trusted');
} catch (err) {
console.error('Failed to connect:', err);
}

View File

@ -30,6 +30,24 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const [selectedEdge, setSelectedEdge] = useState<GraphEdge | null>(null);
// Detect dark mode
const [isDarkMode, setIsDarkMode] = useState(
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
);
// Listen for theme changes
React.useEffect(() => {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
setIsDarkMode(document.documentElement.classList.contains('dark'));
}
});
});
observer.observe(document.documentElement, { attributes: true });
return () => observer.disconnect();
}, []);
// Get collaborators from tldraw
const collaborators = useValue(
'collaborators',
@ -78,9 +96,9 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
useCache: true,
});
// Handle connect with default trust level
const handleConnect = useCallback(async (userId: string) => {
await connect(userId);
// Handle connect with optional trust level
const handleConnect = useCallback(async (userId: string, trustLevel: TrustLevel = 'connected') => {
await connect(userId, trustLevel);
}, [connect]);
// Handle disconnect
@ -142,6 +160,7 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
onExpandClick={handleExpand}
isCollapsed={isCollapsed}
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
isDarkMode={isDarkMode}
/>
);
}

View File

@ -21,6 +21,7 @@ import {
type NetworkGraph,
type GraphNode,
type GraphEdge,
type TrustLevel,
} from '../../lib/networking';
// =============================================================================
@ -53,8 +54,8 @@ export interface UseNetworkGraphOptions {
export interface UseNetworkGraphReturn extends NetworkGraphState {
// Refresh the graph from the server
refresh: () => Promise<void>;
// Connect to a user
connect: (userId: string) => Promise<void>;
// Connect to a user with optional trust level
connect: (userId: string, trustLevel?: TrustLevel) => Promise<void>;
// Disconnect from a user
disconnect: (connectionId: string) => Promise<void>;
// Check if connected to a user
@ -267,9 +268,9 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
}, [participantIds, participantColorMap]);
// Connect to a user
const connect = useCallback(async (userId: string) => {
const connect = useCallback(async (userId: string, trustLevel: TrustLevel = 'connected') => {
try {
await createConnection(userId);
await createConnection(userId, trustLevel);
// Refresh the graph to get updated state
await fetchGraph(true);
clearGraphCache();

View File

@ -20,6 +20,10 @@ interface AuthContextType {
canEdit: () => boolean;
/** Check if user is admin for the current board */
isAdmin: () => boolean;
/** Current access token from URL (if any) */
accessToken: string | null;
/** Set access token (from URL parameter) */
setAccessToken: (token: string | null) => void;
}
const initialSession: Session = {
@ -35,6 +39,22 @@ export const AuthContext = createContext<AuthContextType | undefined>(undefined)
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [session, setSessionState] = useState<Session>(initialSession);
const [accessToken, setAccessTokenState] = useState<string | null>(null);
// Extract access token from URL on mount
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (token) {
console.log('🔑 Access token found in URL');
setAccessTokenState(token);
// Optionally remove from URL to clean it up (but keep the token in state)
// This prevents the token from being shared if someone copies the URL
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete('token');
window.history.replaceState({}, '', newUrl.toString());
}
}, []);
// Update session with partial data
const setSession = useCallback((updatedSession: Partial<Session>) => {
@ -175,12 +195,26 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}
}, [clearSession]);
// Setter for access token
const setAccessToken = useCallback((token: string | null) => {
setAccessTokenState(token);
// Clear cached permissions when token changes (they may be different)
if (token) {
setSessionState(prev => ({
...prev,
boardPermissions: {},
currentBoardPermission: undefined,
}));
}
}, []);
/**
* Fetch and cache the user's permission level for a specific board
* Includes access token if available (from share link)
*/
const fetchBoardPermission = useCallback(async (boardId: string): Promise<PermissionLevel> => {
// Check cache first
if (session.boardPermissions?.[boardId]) {
// Check cache first (but only if no access token - token changes permissions)
if (!accessToken && session.boardPermissions?.[boardId]) {
return session.boardPermissions[boardId];
}
@ -197,23 +231,35 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}
}
const response = await fetch(`${WORKER_URL}/boards/${boardId}/permission`, {
// Build URL with optional access token
let url = `${WORKER_URL}/boards/${boardId}/permission`;
if (accessToken) {
url += `?token=${encodeURIComponent(accessToken)}`;
console.log('🔑 Including access token in permission check');
}
const response = await fetch(url, {
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';
// Default to 'view' for unauthenticated (secure by default)
return 'view';
}
const data = await response.json() as {
permission: PermissionLevel;
isOwner: boolean;
boardExists: boolean;
grantedByToken?: boolean;
};
if (data.grantedByToken) {
console.log('🔓 Permission granted via access token:', data.permission);
}
// Cache the permission
setSessionState(prev => ({
...prev,
@ -227,10 +273,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
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';
// Default to 'view' (secure by default)
return 'view';
}
}, [session.authed, session.username, session.boardPermissions]);
}, [session.authed, session.username, session.boardPermissions, accessToken]);
/**
* Check if user can edit the current board
@ -278,7 +324,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
fetchBoardPermission,
canEdit,
isAdmin,
}), [session, setSession, clearSession, initialize, login, register, logout, fetchBoardPermission, canEdit, isAdmin]);
accessToken,
setAccessToken,
}), [session, setSession, clearSession, initialize, login, register, logout, fetchBoardPermission, canEdit, isAdmin, accessToken, setAccessToken]);
return (
<AuthContext.Provider value={contextValue}>

View File

@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { Editor } from 'tldraw'
import { Editor, TLShapeId } from 'tldraw'
interface OriginalDimensions {
x: number
@ -12,7 +12,7 @@ interface UseMaximizeOptions {
/** Editor instance */
editor: Editor
/** Shape ID to maximize */
shapeId: string
shapeId: TLShapeId
/** Current width of the shape */
currentW: number
/** Current height of the shape */

View File

@ -11,6 +11,7 @@ export interface Session {
authed: boolean;
loading: boolean;
backupCreated: boolean | null;
email?: string; // Email for account backup
obsidianVaultPath?: string;
obsidianVaultName?: string;
error?: string;

View File

@ -30,13 +30,41 @@ const API_BASE = '/api/networking';
// Helper Functions
// =============================================================================
/**
* Get the current user's CryptID username from localStorage session
*/
function getCurrentUserId(): string | null {
try {
const sessionStr = localStorage.getItem('cryptid_session');
if (sessionStr) {
const session = JSON.parse(sessionStr);
if (session.authed && session.username) {
return session.username;
}
}
} catch {
// Ignore parsing errors
}
return null;
}
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
// Get the current user ID for authentication
const userId = getCurrentUserId();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options?.headers as Record<string, string> || {}),
};
// Add user ID header for authentication
if (userId) {
headers['X-User-Id'] = userId;
}
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
headers,
});
if (!response.ok) {

View File

@ -948,28 +948,33 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
)}
</div>
{/* Input Section */}
{/* Input Section - Mobile Optimized */}
<div
style={{
display: "flex",
flexDirection: shape.props.w < 350 ? "column" : "row",
gap: 8,
flexShrink: 0,
padding: "4px 0",
}}
>
<input
<textarea
style={{
flex: 1,
height: "36px",
minHeight: "48px",
height: shape.props.w < 350 ? "60px" : "48px",
backgroundColor: "#fff",
border: "1px solid #ddd",
borderRadius: "6px",
fontSize: 13,
padding: "0 10px",
borderRadius: "8px",
fontSize: 14,
padding: "12px",
touchAction: "manipulation",
minHeight: "44px",
resize: "none",
fontFamily: "inherit",
lineHeight: "1.4",
WebkitAppearance: "none",
}}
type="text"
placeholder="Enter image prompt..."
placeholder="Describe the image you want to generate..."
value={shape.props.prompt}
onChange={(e) => {
editor.updateShape<IImageGen>({
@ -1000,20 +1005,26 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
/>
<button
style={{
height: "36px",
padding: "0 16px",
height: shape.props.w < 350 ? "48px" : "48px",
padding: "0 20px",
pointerEvents: "all",
cursor: shape.props.prompt.trim() && !shape.props.isLoading ? "pointer" : "not-allowed",
backgroundColor: shape.props.prompt.trim() && !shape.props.isLoading ? ImageGenShape.PRIMARY_COLOR : "#ccc",
color: "white",
border: "none",
borderRadius: "6px",
fontWeight: "500",
fontSize: "13px",
borderRadius: "8px",
fontWeight: "600",
fontSize: "14px",
opacity: shape.props.prompt.trim() && !shape.props.isLoading ? 1 : 0.6,
touchAction: "manipulation",
minWidth: "44px",
minHeight: "44px",
minWidth: shape.props.w < 350 ? "100%" : "100px",
minHeight: "48px",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "6px",
transition: "background-color 0.15s, transform 0.1s",
WebkitTapHighlightColor: "transparent",
}}
onPointerDown={(e) => {
e.stopPropagation()
@ -1024,10 +1035,13 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
}}
onTouchStart={(e) => {
e.stopPropagation()
// Visual feedback on touch
e.currentTarget.style.transform = "scale(0.98)"
}}
onTouchEnd={(e) => {
e.stopPropagation()
e.preventDefault()
e.currentTarget.style.transform = "scale(1)"
if (shape.props.prompt.trim() && !shape.props.isLoading) {
handleGenerate()
}
@ -1041,6 +1055,7 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
}}
disabled={shape.props.isLoading || !shape.props.prompt.trim()}
>
<span style={{ fontSize: "16px" }}></span>
Generate
</button>
</div>

View File

@ -105,6 +105,77 @@ const DEFAULT_VIEWPORT: MapViewport = {
const OSRM_BASE_URL = 'https://routing.jeffemmett.com';
// =============================================================================
// Geo Calculation Helpers
// =============================================================================
// Haversine distance calculation (returns meters)
function calculateDistance(coords: Coordinate[]): number {
if (coords.length < 2) return 0;
const R = 6371000; // Earth's radius in meters
let total = 0;
for (let i = 0; i < coords.length - 1; i++) {
const lat1 = coords[i].lat * Math.PI / 180;
const lat2 = coords[i + 1].lat * Math.PI / 180;
const dLat = (coords[i + 1].lat - coords[i].lat) * Math.PI / 180;
const dLng = (coords[i + 1].lng - coords[i].lng) * Math.PI / 180;
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1) * Math.cos(lat2) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
total += R * c;
}
return total;
}
// Shoelace formula for polygon area (returns square meters)
function calculateArea(coords: Coordinate[]): number {
if (coords.length < 3) return 0;
// Convert to projected coordinates (approximate for small areas)
const centerLat = coords.reduce((sum, c) => sum + c.lat, 0) / coords.length;
const metersPerDegreeLat = 111320;
const metersPerDegreeLng = 111320 * Math.cos(centerLat * Math.PI / 180);
const projected = coords.map(c => ({
x: c.lng * metersPerDegreeLng,
y: c.lat * metersPerDegreeLat,
}));
// Shoelace formula
let area = 0;
for (let i = 0; i < projected.length; i++) {
const j = (i + 1) % projected.length;
area += projected[i].x * projected[j].y;
area -= projected[j].x * projected[i].y;
}
return Math.abs(area / 2);
}
// Format distance for display
function formatDistance(meters: number): string {
if (meters < 1000) {
return `${Math.round(meters)} m`;
}
return `${(meters / 1000).toFixed(2)} km`;
}
// Format area for display
function formatArea(sqMeters: number): string {
if (sqMeters < 10000) {
return `${Math.round(sqMeters)}`;
} else if (sqMeters < 1000000) {
return `${(sqMeters / 10000).toFixed(2)} ha`;
}
return `${(sqMeters / 1000000).toFixed(2)} km²`;
}
// Mapus color palette
const COLORS = [
'#E15F59', // Red
@ -331,10 +402,24 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
const activeToolRef = useRef(activeTool); // Ref to track current tool in event handlers
const [selectedColor, setSelectedColor] = useState(COLORS[4]);
// Keep ref in sync with state
// Drawing state for lines and areas
const [drawingPoints, setDrawingPoints] = useState<Coordinate[]>([]);
const drawingPointsRef = useRef<Coordinate[]>([]);
// Keep refs in sync with state
useEffect(() => {
activeToolRef.current = activeTool;
// Clear drawing points when switching tools
if (activeTool !== 'line' && activeTool !== 'area') {
setDrawingPoints([]);
drawingPointsRef.current = [];
}
}, [activeTool]);
useEffect(() => {
drawingPointsRef.current = drawingPoints;
}, [drawingPoints]);
const [showColorPicker, setShowColorPicker] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
@ -418,24 +503,75 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
if (!isMountedRef.current) return;
const coord = { lat: e.lngLat.lat, lng: e.lngLat.lng };
const currentTool = activeToolRef.current;
const currentDrawingPoints = drawingPointsRef.current;
console.log('Map click with tool:', currentTool, 'at', coord);
console.log('Map click with tool:', currentTool, 'at', coord, 'points:', currentDrawingPoints.length);
if (currentTool === 'marker') {
addAnnotation('marker', [coord]);
} else if (currentTool === 'line') {
// Add point to line drawing
const newPoints = [...currentDrawingPoints, coord];
setDrawingPoints(newPoints);
drawingPointsRef.current = newPoints;
} else if (currentTool === 'area') {
// Add point to area drawing
const newPoints = [...currentDrawingPoints, coord];
setDrawingPoints(newPoints);
drawingPointsRef.current = newPoints;
} else if (currentTool === 'eraser') {
// Find and remove annotation at click location
// Check if clicked near any annotation
const clickThreshold = 0.0005; // ~50m at equator
const annotationToRemove = shape.props.annotations.find((ann: Annotation) => {
if (ann.type === 'marker') {
const annCoord = ann.coordinates[0];
return Math.abs(annCoord.lat - coord.lat) < clickThreshold &&
Math.abs(annCoord.lng - coord.lng) < clickThreshold;
} else {
// For lines/areas, check if click is near any point
return ann.coordinates.some((c: Coordinate) =>
Math.abs(c.lat - coord.lat) < clickThreshold &&
Math.abs(c.lng - coord.lng) < clickThreshold
);
}
});
if (annotationToRemove) {
removeAnnotation(annotationToRemove.id);
}
}
};
// Handle double-click to finish line/area drawing
const handleDblClick = (_e: maplibregl.MapMouseEvent) => {
if (!isMountedRef.current) return;
const currentTool = activeToolRef.current;
const currentDrawingPoints = drawingPointsRef.current;
console.log('Map double-click with tool:', currentTool, 'points:', currentDrawingPoints.length);
if (currentTool === 'line' && currentDrawingPoints.length >= 2) {
addAnnotation('line', currentDrawingPoints);
setDrawingPoints([]);
drawingPointsRef.current = [];
} else if (currentTool === 'area' && currentDrawingPoints.length >= 3) {
addAnnotation('area', currentDrawingPoints);
setDrawingPoints([]);
drawingPointsRef.current = [];
}
// TODO: Implement line and area drawing
};
map.on('load', handleLoad);
map.on('moveend', handleMoveEnd);
map.on('click', handleClick);
map.on('dblclick', handleDblClick);
return () => {
// Remove event listeners before destroying map
map.off('load', handleLoad);
map.off('moveend', handleMoveEnd);
map.off('click', handleClick);
map.off('dblclick', handleDblClick);
// Clear all markers
markersRef.current.forEach((marker) => {
@ -576,8 +712,255 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
markersRef.current.delete(id);
}
});
// Render lines and areas
shape.props.annotations.forEach((ann: Annotation) => {
if (!isMountedRef.current || !mapRef.current) return;
if (!ann.visible) {
// Remove layer/source if hidden
try {
if (map.getLayer(`ann-layer-${ann.id}`)) map.removeLayer(`ann-layer-${ann.id}`);
if (ann.type === 'area' && map.getLayer(`ann-fill-${ann.id}`)) map.removeLayer(`ann-fill-${ann.id}`);
if (map.getSource(`ann-source-${ann.id}`)) map.removeSource(`ann-source-${ann.id}`);
} catch (err) { /* ignore */ }
return;
}
if (ann.type === 'line' && ann.coordinates.length >= 2) {
const coords = ann.coordinates.map((c: Coordinate) => [c.lng, c.lat]);
const sourceId = `ann-source-${ann.id}`;
const layerId = `ann-layer-${ann.id}`;
try {
if (map.getSource(sourceId)) {
(map.getSource(sourceId) as maplibregl.GeoJSONSource).setData({
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: coords },
});
} else {
map.addSource(sourceId, {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: coords },
},
});
map.addLayer({
id: layerId,
type: 'line',
source: sourceId,
paint: {
'line-color': ann.color,
'line-width': 4,
'line-opacity': 0.8,
},
});
}
} catch (err) {
console.warn('Error rendering line:', err);
}
} else if (ann.type === 'area' && ann.coordinates.length >= 3) {
const coords = ann.coordinates.map((c: Coordinate) => [c.lng, c.lat]);
// Close the polygon
const closedCoords = [...coords, coords[0]];
const sourceId = `ann-source-${ann.id}`;
const fillLayerId = `ann-fill-${ann.id}`;
const lineLayerId = `ann-layer-${ann.id}`;
try {
if (map.getSource(sourceId)) {
(map.getSource(sourceId) as maplibregl.GeoJSONSource).setData({
type: 'Feature',
properties: {},
geometry: { type: 'Polygon', coordinates: [closedCoords] },
});
} else {
map.addSource(sourceId, {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: { type: 'Polygon', coordinates: [closedCoords] },
},
});
map.addLayer({
id: fillLayerId,
type: 'fill',
source: sourceId,
paint: {
'fill-color': ann.color,
'fill-opacity': 0.3,
},
});
map.addLayer({
id: lineLayerId,
type: 'line',
source: sourceId,
paint: {
'line-color': ann.color,
'line-width': 3,
'line-opacity': 0.8,
},
});
}
} catch (err) {
console.warn('Error rendering area:', err);
}
}
});
// Clean up removed annotation layers
currentIds.forEach((id) => {
const ann = shape.props.annotations.find((a: Annotation) => a.id === id);
if (!ann) {
try {
if (map.getLayer(`ann-layer-${id}`)) map.removeLayer(`ann-layer-${id}`);
if (map.getLayer(`ann-fill-${id}`)) map.removeLayer(`ann-fill-${id}`);
if (map.getSource(`ann-source-${id}`)) map.removeSource(`ann-source-${id}`);
} catch (err) { /* ignore */ }
}
});
}, [shape.props.annotations, isLoaded]);
// ==========================================================================
// Drawing Preview (for lines/areas in progress)
// ==========================================================================
useEffect(() => {
if (!mapRef.current || !isLoaded || !isMountedRef.current) return;
const map = mapRef.current;
const sourceId = 'drawing-preview';
const lineLayerId = 'drawing-preview-line';
const fillLayerId = 'drawing-preview-fill';
const pointsLayerId = 'drawing-preview-points';
try {
// Remove existing preview layers first
if (map.getLayer(pointsLayerId)) map.removeLayer(pointsLayerId);
if (map.getLayer(fillLayerId)) map.removeLayer(fillLayerId);
if (map.getLayer(lineLayerId)) map.removeLayer(lineLayerId);
if (map.getSource(sourceId)) map.removeSource(sourceId);
if (drawingPoints.length === 0) return;
const coords = drawingPoints.map((c) => [c.lng, c.lat]);
if (activeTool === 'line' && coords.length >= 1) {
map.addSource(sourceId, {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
...(coords.length >= 2 ? [{
type: 'Feature' as const,
properties: {},
geometry: { type: 'LineString' as const, coordinates: coords },
}] : []),
...coords.map((coord) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'Point' as const, coordinates: coord },
})),
],
},
});
if (coords.length >= 2) {
map.addLayer({
id: lineLayerId,
type: 'line',
source: sourceId,
paint: {
'line-color': selectedColor,
'line-width': 4,
'line-opacity': 0.6,
'line-dasharray': [2, 2],
},
});
}
map.addLayer({
id: pointsLayerId,
type: 'circle',
source: sourceId,
filter: ['==', '$type', 'Point'],
paint: {
'circle-radius': 6,
'circle-color': selectedColor,
'circle-stroke-width': 2,
'circle-stroke-color': '#fff',
},
});
} else if (activeTool === 'area' && coords.length >= 1) {
const closedCoords = coords.length >= 3 ? [...coords, coords[0]] : coords;
map.addSource(sourceId, {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
...(coords.length >= 3 ? [{
type: 'Feature' as const,
properties: {},
geometry: { type: 'Polygon' as const, coordinates: [closedCoords] },
}] : []),
...(coords.length >= 2 ? [{
type: 'Feature' as const,
properties: {},
geometry: { type: 'LineString' as const, coordinates: coords },
}] : []),
...coords.map((coord) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'Point' as const, coordinates: coord },
})),
],
},
});
if (coords.length >= 3) {
map.addLayer({
id: fillLayerId,
type: 'fill',
source: sourceId,
filter: ['==', '$type', 'Polygon'],
paint: {
'fill-color': selectedColor,
'fill-opacity': 0.2,
},
});
}
if (coords.length >= 2) {
map.addLayer({
id: lineLayerId,
type: 'line',
source: sourceId,
filter: ['==', '$type', 'LineString'],
paint: {
'line-color': selectedColor,
'line-width': 3,
'line-opacity': 0.6,
'line-dasharray': [2, 2],
},
});
}
map.addLayer({
id: pointsLayerId,
type: 'circle',
source: sourceId,
filter: ['==', '$type', 'Point'],
paint: {
'circle-radius': 6,
'circle-color': selectedColor,
'circle-stroke-width': 2,
'circle-stroke-color': '#fff',
},
});
}
} catch (err) {
console.warn('Error rendering drawing preview:', err);
}
}, [drawingPoints, activeTool, selectedColor, isLoaded]);
// ==========================================================================
// Collaborator presence (cursors/locations)
// ==========================================================================
@ -1121,8 +1504,20 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
borderRadius: '50%',
background: ann.color,
}} />
<div style={{ flex: 1, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ann.name}
<div style={{ flex: 1, overflow: 'hidden' }}>
<div style={{ fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ann.name}
</div>
{ann.type === 'line' && ann.coordinates.length >= 2 && (
<div style={{ fontSize: 10, color: '#666', marginTop: 1 }}>
📏 {formatDistance(calculateDistance(ann.coordinates))}
</div>
)}
{ann.type === 'area' && ann.coordinates.length >= 3 && (
<div style={{ fontSize: 10, color: '#666', marginTop: 1 }}>
{formatArea(calculateArea(ann.coordinates))} {formatDistance(calculateDistance([...ann.coordinates, ann.coordinates[0]]))} perimeter
</div>
)}
</div>
<button
onClick={(e) => { e.stopPropagation(); toggleAnnotationVisibility(ann.id); }}
@ -1273,6 +1668,43 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
</button>
</div>
{/* Measurement Display and Drawing Instructions */}
{(activeTool === 'line' || activeTool === 'area') && (
<div
style={{
position: 'absolute',
bottom: 80,
left: '50%',
transform: 'translateX(-50%)',
background: '#fff',
borderRadius: 8,
boxShadow: '0 2px 12px rgba(0,0,0,0.15)',
padding: '8px 14px',
zIndex: 10000,
pointerEvents: 'none',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 4,
}}
>
{drawingPoints.length > 0 && (
<div style={{ fontSize: 14, fontWeight: 600, color: selectedColor }}>
{activeTool === 'line' && formatDistance(calculateDistance(drawingPoints))}
{activeTool === 'area' && drawingPoints.length >= 3 && formatArea(calculateArea(drawingPoints))}
{activeTool === 'area' && drawingPoints.length < 3 && `${drawingPoints.length}/3 points`}
</div>
)}
<div style={{ fontSize: 11, color: '#666' }}>
{drawingPoints.length === 0 && 'Click to start drawing'}
{drawingPoints.length > 0 && activeTool === 'line' && drawingPoints.length < 2 && 'Click to add more points'}
{drawingPoints.length >= 2 && activeTool === 'line' && 'Double-click to finish line'}
{drawingPoints.length > 0 && activeTool === 'area' && drawingPoints.length < 3 && 'Click to add more points'}
{drawingPoints.length >= 3 && activeTool === 'area' && 'Double-click to finish area'}
</div>
</div>
)}
{/* Drawing Toolbar (Mapus-style) */}
<div style={styles.toolbar} onPointerDown={stopPropagation}>
{/* Cursor Tool */}

View File

@ -7,8 +7,6 @@ import { useDialogs } from "tldraw"
import { SettingsDialog } from "./SettingsDialog"
import { useAuth } from "../context/AuthContext"
import LoginButton from "../components/auth/LoginButton"
import StarBoardButton from "../components/StarBoardButton"
import ShareBoardButton from "../components/ShareBoardButton"
import { ObsidianVaultBrowser } from "../components/ObsidianVaultBrowser"
import { HolonBrowser } from "../components/HolonBrowser"
import { ObsNoteShape } from "../shapes/ObsNoteShapeUtil"
@ -645,790 +643,7 @@ export function CustomToolbar() {
if (!isReady) return null
return (
<div style={{ position: "relative" }}>
<div
className="toolbar-container"
style={{
pointerEvents: "auto",
}}
>
<LoginButton className="toolbar-btn" />
<ShareBoardButton className="toolbar-btn" />
<StarBoardButton className="toolbar-btn" />
{session.authed && (
<div style={{ position: "relative" }}>
<button
className="toolbar-btn profile-btn"
onClick={() => setShowProfilePopup(!showProfilePopup)}
title={`Signed in as ${session.username}`}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>
</svg>
<span className="profile-username">{session.username}</span>
</button>
{showProfilePopup && (
<div ref={profilePopupRef} className="profile-dropdown" style={{ width: '280px', maxHeight: '80vh', overflowY: 'auto' }}>
<div className="profile-dropdown-header">
<div className="profile-avatar">
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>
</svg>
</div>
<div className="profile-info">
<span className="profile-name">{session.username}</span>
<span className="profile-label">CryptID Account</span>
</div>
</div>
<div className="profile-dropdown-divider" />
<a href="/dashboard/" className="profile-dropdown-item">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09z"/>
</svg>
<span>My Saved Boards</span>
</a>
<div className="profile-dropdown-divider" />
{/* General Settings */}
<button className="profile-dropdown-item" onClick={toggleDarkMode}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
{isDarkMode ? (
<path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm.5-9.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm0 11a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm5-5a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-11 0a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm9.743-4.036a.5.5 0 1 1-.707-.707.5.5 0 0 1 .707.707zm-7.779 7.779a.5.5 0 1 1-.707-.707.5.5 0 0 1 .707.707zm7.072 0a.5.5 0 1 1 .707-.707.5.5 0 0 1-.707.707zM3.757 4.464a.5.5 0 1 1 .707-.707.5.5 0 0 1-.707.707z"/>
) : (
<path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
)}
</svg>
<span>{isDarkMode ? 'Light Mode' : 'Dark Mode'}</span>
</button>
<div className="profile-dropdown-divider" />
{/* AI Models Section */}
<button
className="profile-dropdown-item"
onClick={() => setExpandedSection(expandedSection === 'ai' ? 'none' : 'ai')}
style={{ justifyContent: 'space-between' }}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px' }}>🤖</span>
<span>AI Models</span>
</span>
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="currentColor"
style={{ transform: expandedSection === 'ai' ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
>
<path d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
{expandedSection === 'ai' && (
<div style={{ padding: '8px 12px', backgroundColor: 'var(--color-muted-2, #f5f5f5)' }}>
<p style={{ fontSize: '10px', color: 'var(--color-text-2, #666)', marginBottom: '8px' }}>
Local models are free. Cloud models require API keys.
</p>
{AI_TOOLS.map((tool) => (
<div
key={tool.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 0',
borderBottom: '1px solid var(--color-muted-1, #eee)',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
<span>{tool.icon}</span>
<span>{tool.name}</span>
</span>
<span
style={{
fontSize: '9px',
padding: '2px 6px',
borderRadius: '10px',
backgroundColor: tool.type === 'local' ? '#d1fae5' : tool.type === 'gpu' ? '#e0e7ff' : '#fef3c7',
color: tool.type === 'local' ? '#065f46' : tool.type === 'gpu' ? '#3730a3' : '#92400e',
fontWeight: 500,
}}
>
{tool.model}
</span>
</div>
))}
<button
onClick={() => {
addDialog({
id: "api-keys",
component: ({ onClose: dialogClose }: { onClose: () => void }) => (
<SettingsDialog
onClose={() => {
dialogClose()
removeDialog("api-keys")
checkApiKeys()
}}
/>
),
})
}}
style={{
width: '100%',
marginTop: '8px',
padding: '6px 10px',
fontSize: '11px',
fontWeight: 500,
backgroundColor: 'var(--color-primary, #3b82f6)',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
{hasApiKey ? 'Manage API Keys' : 'Add API Keys'}
</button>
</div>
)}
{/* Integrations Section */}
<button
className="profile-dropdown-item"
onClick={() => setExpandedSection(expandedSection === 'integrations' ? 'none' : 'integrations')}
style={{ justifyContent: 'space-between' }}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px' }}>🔗</span>
<span>Integrations</span>
</span>
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="currentColor"
style={{ transform: expandedSection === 'integrations' ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
>
<path d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
{expandedSection === 'integrations' && (
<div style={{ padding: '8px 12px', backgroundColor: 'var(--color-muted-2, #f5f5f5)' }}>
{/* Obsidian Vault */}
<div style={{ marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '4px' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', fontWeight: 500 }}>
<span>📁</span> Obsidian Vault
</span>
<span
style={{
fontSize: '9px',
padding: '2px 6px',
borderRadius: '10px',
backgroundColor: session.obsidianVaultName ? '#d1fae5' : '#fef3c7',
color: session.obsidianVaultName ? '#065f46' : '#92400e',
fontWeight: 500,
}}
>
{session.obsidianVaultName ? 'Connected' : 'Not Set'}
</span>
</div>
{session.obsidianVaultName && (
<p style={{ fontSize: '10px', color: '#059669', marginBottom: '4px' }}>{session.obsidianVaultName}</p>
)}
<button
onClick={() => {
window.dispatchEvent(new CustomEvent('open-obsidian-browser'))
setShowProfilePopup(false)
}}
style={{
width: '100%',
padding: '5px 8px',
fontSize: '10px',
backgroundColor: 'white',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
}}
>
{session.obsidianVaultName ? 'Change Vault' : 'Connect Vault'}
</button>
</div>
{/* Fathom Meetings */}
<div style={{ paddingTop: '8px', borderTop: '1px solid var(--color-muted-1, #ddd)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '4px' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', fontWeight: 500 }}>
<span>🎥</span> Fathom Meetings
</span>
<span
style={{
fontSize: '9px',
padding: '2px 6px',
borderRadius: '10px',
backgroundColor: hasFathomApiKey ? '#d1fae5' : '#fef3c7',
color: hasFathomApiKey ? '#065f46' : '#92400e',
fontWeight: 500,
}}
>
{hasFathomApiKey ? 'Connected' : 'Not Set'}
</span>
</div>
{showFathomInput ? (
<div>
<input
type="password"
value={fathomKeyInput}
onChange={(e) => setFathomKeyInput(e.target.value)}
placeholder="Enter Fathom API key..."
style={{
width: '100%',
padding: '6px 8px',
fontSize: '11px',
border: '1px solid #ddd',
borderRadius: '4px',
marginBottom: '6px',
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && fathomKeyInput.trim()) {
saveFathomApiKey(fathomKeyInput.trim(), session.username)
setHasFathomApiKey(true)
setShowFathomInput(false)
setFathomKeyInput('')
} else if (e.key === 'Escape') {
setShowFathomInput(false)
setFathomKeyInput('')
}
}}
autoFocus
/>
<div style={{ display: 'flex', gap: '4px' }}>
<button
onClick={() => {
if (fathomKeyInput.trim()) {
saveFathomApiKey(fathomKeyInput.trim(), session.username)
setHasFathomApiKey(true)
setShowFathomInput(false)
setFathomKeyInput('')
}
}}
style={{
flex: 1,
padding: '5px',
fontSize: '10px',
backgroundColor: 'var(--color-primary, #3b82f6)',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Save
</button>
<button
onClick={() => {
setShowFathomInput(false)
setFathomKeyInput('')
}}
style={{
flex: 1,
padding: '5px',
fontSize: '10px',
backgroundColor: 'white',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
<a
href="https://app.usefathom.com/settings/integrations"
target="_blank"
rel="noopener noreferrer"
style={{ display: 'block', fontSize: '9px', color: '#3b82f6', marginTop: '6px', textDecoration: 'none' }}
>
Get API key from Fathom
</a>
</div>
) : (
<div style={{ display: 'flex', gap: '4px' }}>
<button
onClick={() => {
setShowFathomInput(true)
const currentKey = getFathomApiKey(session.username)
if (currentKey) setFathomKeyInput(currentKey)
}}
style={{
flex: 1,
padding: '5px 8px',
fontSize: '10px',
backgroundColor: 'white',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
}}
>
{hasFathomApiKey ? 'Change Key' : 'Add API Key'}
</button>
{hasFathomApiKey && (
<button
onClick={() => {
removeFathomApiKey(session.username)
setHasFathomApiKey(false)
}}
style={{
padding: '5px 8px',
fontSize: '10px',
backgroundColor: '#fee2e2',
color: '#dc2626',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Disconnect
</button>
)}
</div>
)}
</div>
</div>
)}
{/* Connections Section */}
<button
className="profile-dropdown-item"
onClick={() => setExpandedSection(expandedSection === 'connections' ? 'none' : 'connections')}
style={{ justifyContent: 'space-between' }}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px' }}>🕸</span>
<span>Connections</span>
{connections.length > 0 && (
<span style={{
fontSize: '10px',
padding: '1px 6px',
borderRadius: '10px',
backgroundColor: 'var(--color-muted-2, #e5e7eb)',
color: 'var(--color-text-2, #666)',
}}>
{connections.length}
</span>
)}
</span>
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="currentColor"
style={{ transform: expandedSection === 'connections' ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
>
<path d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
{expandedSection === 'connections' && (
<div style={{ padding: '8px 12px', backgroundColor: 'var(--color-muted-2, #f5f5f5)', maxHeight: '400px', overflowY: 'auto' }}>
{/* People in Canvas Section */}
{canvasUsers.length > 0 && (
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-2, #666)', marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<span>People in Canvas</span>
<span style={{
fontSize: '9px',
padding: '1px 5px',
borderRadius: '8px',
backgroundColor: 'var(--color-primary, #3b82f6)',
color: 'white',
}}>
{canvasUsers.length}
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{canvasUsers.map((user) => (
<div
key={user.id}
style={{
padding: '8px',
backgroundColor: 'white',
borderRadius: '6px',
border: '1px solid var(--color-muted-1, #e5e7eb)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{/* User avatar with presence color */}
<div
style={{
width: '28px',
height: '28px',
borderRadius: '50%',
backgroundColor: user.color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
color: 'white',
fontWeight: 600,
border: `2px solid ${
user.connectionStatus === 'trusted' ? TRUST_LEVEL_COLORS.trusted :
user.connectionStatus === 'connected' ? TRUST_LEVEL_COLORS.connected :
TRUST_LEVEL_COLORS.unconnected
}`,
}}
>
{user.name.charAt(0).toUpperCase()}
</div>
<div>
<div style={{ fontSize: '12px', fontWeight: 500 }}>
{user.name}
</div>
<div style={{ fontSize: '10px', color: 'var(--color-text-2, #666)' }}>
{user.connectionStatus === 'trusted' ? 'Trusted' :
user.connectionStatus === 'connected' ? 'Connected' :
'Not connected'}
</div>
</div>
</div>
{/* Connection status indicator & actions */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{connectingUserId === user.id ? (
<span style={{ fontSize: '10px', color: 'var(--color-text-2, #666)' }}>...</span>
) : user.connectionStatus === 'unconnected' ? (
<>
<button
onClick={() => handleConnect(user.id, 'connected')}
style={{
padding: '3px 8px',
fontSize: '10px',
backgroundColor: TRUST_LEVEL_COLORS.connected,
color: 'black',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
title="Add as Connected (view access)"
>
+ Connect
</button>
<button
onClick={() => handleConnect(user.id, 'trusted')}
style={{
padding: '3px 8px',
fontSize: '10px',
backgroundColor: TRUST_LEVEL_COLORS.trusted,
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
title="Add as Trusted (edit access)"
>
+ Trust
</button>
</>
) : (
<>
{/* Toggle between connected and trusted */}
{user.connectionStatus === 'connected' ? (
<button
onClick={() => handleChangeTrust(user.connectionId!, user.id, 'trusted')}
style={{
padding: '3px 8px',
fontSize: '10px',
backgroundColor: TRUST_LEVEL_COLORS.trusted,
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
title="Upgrade to Trusted (edit access)"
>
Trust
</button>
) : (
<button
onClick={() => handleChangeTrust(user.connectionId!, user.id, 'connected')}
style={{
padding: '3px 8px',
fontSize: '10px',
backgroundColor: TRUST_LEVEL_COLORS.connected,
color: 'black',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
title="Downgrade to Connected (view only)"
>
Demote
</button>
)}
<button
onClick={() => handleDisconnect(user.connectionId!, user.id)}
style={{
padding: '3px 6px',
fontSize: '10px',
backgroundColor: '#fee2e2',
color: '#dc2626',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
title="Remove connection"
>
</button>
</>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Divider if both sections exist */}
{canvasUsers.length > 0 && connections.length > 0 && (
<div style={{ borderTop: '1px solid var(--color-muted-1, #ddd)', marginBottom: '12px' }} />
)}
{/* My Connections Section */}
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-2, #666)', marginBottom: '8px' }}>
My Connections
</div>
{connectionsLoading ? (
<p style={{ fontSize: '11px', color: 'var(--color-text-2, #666)', textAlign: 'center', padding: '12px 0' }}>
Loading connections...
</p>
) : connections.length === 0 ? (
<div style={{ textAlign: 'center', padding: '12px 0' }}>
<p style={{ fontSize: '11px', color: 'var(--color-text-2, #666)', marginBottom: '8px' }}>
No connections yet
</p>
<p style={{ fontSize: '10px', color: 'var(--color-text-3, #999)' }}>
Connect with people in the canvas above
</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{connections.map((conn) => (
<div
key={conn.id}
style={{
padding: '8px',
backgroundColor: 'white',
borderRadius: '6px',
border: '1px solid var(--color-muted-1, #e5e7eb)',
}}
>
{/* Connection Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '6px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
backgroundColor: conn.toProfile?.avatarColor || TRUST_LEVEL_COLORS[conn.trustLevel],
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
color: 'white',
fontWeight: 600,
}}
>
{(conn.toProfile?.displayName || conn.toUserId).charAt(0).toUpperCase()}
</div>
<div>
<div style={{ fontSize: '12px', fontWeight: 500 }}>
{conn.toProfile?.displayName || conn.toUserId}
</div>
<div style={{ fontSize: '10px', color: 'var(--color-text-2, #666)' }}>
@{conn.toUserId}
</div>
</div>
</div>
<span
style={{
fontSize: '9px',
padding: '2px 6px',
borderRadius: '10px',
backgroundColor: conn.trustLevel === 'trusted' ? '#d1fae5' : '#fef3c7',
color: conn.trustLevel === 'trusted' ? '#065f46' : '#92400e',
fontWeight: 500,
}}
>
{conn.trustLevel === 'trusted' ? 'Trusted' : 'Connected'}
</span>
</div>
{/* Mutual Connection Badge */}
{conn.isMutual && (
<div style={{
fontSize: '9px',
color: '#059669',
backgroundColor: '#d1fae5',
padding: '2px 6px',
borderRadius: '4px',
marginBottom: '6px',
display: 'inline-block',
}}>
Mutual connection
</div>
)}
{/* Edge Metadata Display/Edit */}
{editingConnectionId === conn.id ? (
<div style={{ marginTop: '8px', padding: '8px', backgroundColor: 'var(--color-muted-2, #f5f5f5)', borderRadius: '4px' }}>
<div style={{ marginBottom: '6px' }}>
<label style={{ fontSize: '10px', color: 'var(--color-text-2, #666)', display: 'block', marginBottom: '2px' }}>Label</label>
<input
type="text"
value={editingMetadata.label || ''}
onChange={(e) => setEditingMetadata({ ...editingMetadata, label: e.target.value })}
placeholder="e.g., Colleague, Friend..."
style={{ width: '100%', padding: '4px 6px', fontSize: '11px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
<div style={{ marginBottom: '6px' }}>
<label style={{ fontSize: '10px', color: 'var(--color-text-2, #666)', display: 'block', marginBottom: '2px' }}>Notes (private)</label>
<textarea
value={editingMetadata.notes || ''}
onChange={(e) => setEditingMetadata({ ...editingMetadata, notes: e.target.value })}
placeholder="Private notes about this connection..."
style={{ width: '100%', padding: '4px 6px', fontSize: '11px', border: '1px solid #ddd', borderRadius: '4px', minHeight: '50px', resize: 'vertical' }}
/>
</div>
<div style={{ marginBottom: '6px' }}>
<label style={{ fontSize: '10px', color: 'var(--color-text-2, #666)', display: 'block', marginBottom: '2px' }}>Strength (1-10)</label>
<input
type="range"
min="1"
max="10"
value={editingMetadata.strength || 5}
onChange={(e) => setEditingMetadata({ ...editingMetadata, strength: parseInt(e.target.value) })}
style={{ width: '100%' }}
/>
<div style={{ fontSize: '10px', textAlign: 'center', color: 'var(--color-text-2, #666)' }}>{editingMetadata.strength || 5}</div>
</div>
<div style={{ display: 'flex', gap: '4px', marginTop: '8px' }}>
<button
onClick={() => handleSaveMetadata(conn.id)}
disabled={savingMetadata}
style={{
flex: 1,
padding: '5px',
fontSize: '10px',
backgroundColor: 'var(--color-primary, #3b82f6)',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: savingMetadata ? 'not-allowed' : 'pointer',
opacity: savingMetadata ? 0.6 : 1,
}}
>
{savingMetadata ? 'Saving...' : 'Save'}
</button>
<button
onClick={() => {
setEditingConnectionId(null)
setEditingMetadata({})
}}
style={{
flex: 1,
padding: '5px',
fontSize: '10px',
backgroundColor: 'white',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>
) : (
<div>
{/* Show existing metadata if any */}
{conn.metadata && (conn.metadata.label || conn.metadata.notes) && (
<div style={{ marginTop: '6px', padding: '6px', backgroundColor: 'var(--color-muted-2, #f5f5f5)', borderRadius: '4px' }}>
{conn.metadata.label && (
<div style={{ fontSize: '11px', fontWeight: 500, marginBottom: '2px' }}>
{conn.metadata.label}
</div>
)}
{conn.metadata.notes && (
<div style={{ fontSize: '10px', color: 'var(--color-text-2, #666)' }}>
{conn.metadata.notes}
</div>
)}
{conn.metadata.strength && (
<div style={{ fontSize: '9px', color: 'var(--color-text-3, #999)', marginTop: '4px' }}>
Strength: {conn.metadata.strength}/10
</div>
)}
</div>
)}
<button
onClick={() => {
setEditingConnectionId(conn.id)
setEditingMetadata(conn.metadata || {})
}}
style={{
marginTop: '6px',
width: '100%',
padding: '4px 8px',
fontSize: '10px',
backgroundColor: 'transparent',
border: '1px dashed #ddd',
borderRadius: '4px',
cursor: 'pointer',
color: 'var(--color-text-2, #666)',
}}
>
{conn.metadata?.label || conn.metadata?.notes ? 'Edit details' : 'Add details'}
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
<div className="profile-dropdown-divider" />
{!session.backupCreated && (
<div className="profile-dropdown-warning">
Back up your encryption keys to prevent data loss
</div>
)}
<button className="profile-dropdown-item danger" onClick={handleLogout}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path fillRule="evenodd" d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z"/>
<path fillRule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"/>
</svg>
<span>Sign Out</span>
</button>
</div>
)}
</div>
)}
</div>
<>
<DefaultToolbar>
<DefaultToolbarContent />
{tools["VideoChat"] && (
@ -1572,14 +787,13 @@ export function CustomToolbar() {
)
})()}
</DefaultToolbar>
{/* Fathom Meetings Panel */}
{showFathomPanel && (
<FathomMeetingsPanel
onClose={() => setShowFathomPanel(false)}
/>
)}
</div>
</>
)
}

View File

@ -16,24 +16,48 @@ interface InviteDialogProps extends TLUiDialogProps {
boardSlug: string
}
type TabType = 'qr' | 'url' | 'nfc' | 'audio'
type PermissionType = 'view' | 'edit' | 'admin'
const PERMISSION_LABELS: Record<PermissionType, { label: string; description: string; color: string }> = {
view: { label: 'View', description: 'Can view but not edit', color: '#6b7280' },
edit: { label: 'Edit', description: 'Can view and edit', color: '#3b82f6' },
admin: { label: 'Admin', description: 'Full control', color: '#10b981' },
}
export function InviteDialog({ onClose, boardUrl, boardSlug }: InviteDialogProps) {
const [activeTab, setActiveTab] = useState<TabType>('qr')
const [copied, setCopied] = useState(false)
const [nfcStatus, setNfcStatus] = useState<'idle' | 'writing' | 'success' | 'error' | 'unsupported'>('idle')
const [nfcMessage, setNfcMessage] = useState('')
const [permission, setPermission] = useState<PermissionType>('edit')
// Check NFC support on mount
// Check NFC support on mount and add ESC key handler
useEffect(() => {
if (!('NDEFReader' in window)) {
setNfcStatus('unsupported')
}
}, [])
// ESC key handler to close dialog
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
onClose()
}
}
document.addEventListener('keydown', handleKeyDown, true)
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [onClose])
// Generate URL with permission parameter
const getShareUrl = () => {
const url = new URL(boardUrl)
url.searchParams.set('access', permission)
return url.toString()
}
const handleCopyUrl = async () => {
try {
await navigator.clipboard.writeText(boardUrl)
await navigator.clipboard.writeText(getShareUrl())
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
@ -55,7 +79,7 @@ export function InviteDialog({ onClose, boardUrl, boardSlug }: InviteDialogProps
const ndef = new (window as any).NDEFReader()
await ndef.write({
records: [
{ recordType: "url", data: boardUrl }
{ recordType: "url", data: getShareUrl() }
]
})
@ -78,26 +102,13 @@ export function InviteDialog({ onClose, boardUrl, boardSlug }: InviteDialogProps
}
}
const tabStyle = (tab: TabType) => ({
flex: 1,
padding: '10px 16px',
border: 'none',
background: activeTab === tab ? '#3b82f6' : '#f3f4f6',
color: activeTab === tab ? 'white' : '#374151',
cursor: 'pointer',
fontWeight: activeTab === tab ? 600 : 400,
fontSize: '13px',
transition: 'all 0.2s ease',
borderRadius: tab === 'qr' ? '6px 0 0 6px' : tab === 'audio' ? '0 6px 6px 0' : '0',
})
return (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>Invite to Board</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody style={{ maxWidth: 420, minHeight: 380 }}>
<TldrawUiDialogBody style={{ maxWidth: 420 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Board name display */}
<div style={{
@ -111,228 +122,230 @@ export function InviteDialog({ onClose, boardUrl, boardSlug }: InviteDialogProps
<span style={{ fontSize: '14px', fontWeight: 600, color: '#1e293b' }}>{boardSlug}</span>
</div>
{/* Tab navigation */}
<div style={{ display: 'flex', borderRadius: '6px', overflow: 'hidden' }}>
<button style={tabStyle('qr')} onClick={() => setActiveTab('qr')}>
QR Code
</button>
<button style={tabStyle('url')} onClick={() => setActiveTab('url')}>
URL
</button>
<button style={tabStyle('nfc')} onClick={() => setActiveTab('nfc')}>
NFC
</button>
<button style={tabStyle('audio')} onClick={() => setActiveTab('audio')}>
Audio
</button>
</div>
{/* Tab content */}
{/* Permission selector */}
<div style={{
minHeight: 220,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
}}>
<span style={{ fontSize: '12px', color: '#64748b', fontWeight: 500 }}>Access Level</span>
<div style={{ display: 'flex', gap: '8px' }}>
{(['view', 'edit', 'admin'] as PermissionType[]).map((perm) => {
const isActive = permission === perm
const { label, description, color } = PERMISSION_LABELS[perm]
return (
<button
key={perm}
onClick={() => setPermission(perm)}
style={{
flex: 1,
padding: '10px 8px',
border: isActive ? `2px solid ${color}` : '2px solid #e5e7eb',
background: isActive ? `${color}10` : 'white',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.15s ease',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.borderColor = color
e.currentTarget.style.background = `${color}08`
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.borderColor = '#e5e7eb'
e.currentTarget.style.background = 'white'
}
}}
>
<span style={{
fontSize: '13px',
fontWeight: 600,
color: isActive ? color : '#374151'
}}>
{label}
</span>
<span style={{
fontSize: '10px',
color: '#9ca3af',
lineHeight: 1.2,
}}>
{description}
</span>
</button>
)
})}
</div>
</div>
{/* QR Code and URL side by side */}
<div style={{
display: 'flex',
gap: '16px',
padding: '16px',
backgroundColor: '#fafafa',
borderRadius: '8px',
border: '1px solid #e5e7eb'
}}>
{activeTab === 'qr' && (
<div style={{ textAlign: 'center' }}>
<div style={{
padding: '16px',
backgroundColor: 'white',
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
display: 'inline-block'
}}>
<QRCodeSVG
value={boardUrl}
size={180}
level="M"
includeMargin={false}
/>
</div>
<p style={{
marginTop: '16px',
fontSize: '13px',
color: '#6b7280',
lineHeight: 1.5
}}>
Scan this QR code with a mobile device to join the board
</p>
</div>
)}
{/* QR Code */}
<div style={{
padding: '12px',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 4px rgba(0,0,0,0.1)',
flexShrink: 0,
}}>
<QRCodeSVG
value={getShareUrl()}
size={120}
level="M"
includeMargin={false}
/>
</div>
{activeTab === 'url' && (
<div style={{ width: '100%', textAlign: 'center' }}>
<div style={{
padding: '12px',
backgroundColor: 'white',
borderRadius: '8px',
border: '1px solid #d1d5db',
marginBottom: '16px',
wordBreak: 'break-all',
fontSize: '13px',
fontFamily: 'monospace',
color: '#374151'
}}>
{boardUrl}
</div>
<button
onClick={handleCopyUrl}
style={{
padding: '10px 24px',
backgroundColor: copied ? '#10b981' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '8px',
margin: '0 auto'
}}
>
{copied ? (
<>
<span>Copied!</span>
</>
) : (
<>
<span>Copy URL</span>
</>
)}
</button>
<p style={{
marginTop: '16px',
fontSize: '13px',
color: '#6b7280'
}}>
Share this link with anyone to invite them to your board
</p>
{/* URL and Copy Button */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: '12px' }}>
<div style={{
padding: '10px',
backgroundColor: 'white',
borderRadius: '6px',
border: '1px solid #d1d5db',
wordBreak: 'break-all',
fontSize: '11px',
fontFamily: 'monospace',
color: '#374151',
lineHeight: 1.4,
maxHeight: '60px',
overflowY: 'auto',
}}>
{getShareUrl()}
</div>
)}
{activeTab === 'nfc' && (
<div style={{ width: '100%', textAlign: 'center' }}>
{nfcStatus === 'unsupported' ? (
<button
onClick={handleCopyUrl}
style={{
padding: '8px 16px',
backgroundColor: copied ? '#10b981' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
}}
>
{copied ? (
<>
<div style={{
fontSize: '48px',
marginBottom: '16px',
opacity: 0.5
}}>
NFC
</div>
<p style={{
fontSize: '14px',
color: '#6b7280',
marginBottom: '8px'
}}>
NFC is not supported on this device
</p>
<p style={{
fontSize: '12px',
color: '#9ca3af'
}}>
Try using a mobile device with NFC capability
</p>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
<span>Copied!</span>
</>
) : (
<>
<div style={{
fontSize: '48px',
marginBottom: '16px',
animation: nfcStatus === 'writing' ? 'pulse 1.5s infinite' : 'none'
}}>
{nfcStatus === 'success' ? '(done)' : nfcStatus === 'error' ? '(!)' : 'NFC'}
</div>
<button
onClick={handleNfcWrite}
disabled={nfcStatus === 'writing'}
style={{
padding: '10px 24px',
backgroundColor: nfcStatus === 'success' ? '#10b981' :
nfcStatus === 'error' ? '#ef4444' :
nfcStatus === 'writing' ? '#9ca3af' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: nfcStatus === 'writing' ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 500,
marginBottom: '12px'
}}
>
{nfcStatus === 'writing' ? 'Writing...' :
nfcStatus === 'success' ? 'Written!' :
'Write to NFC Tag'}
</button>
{nfcMessage && (
<p style={{
fontSize: '13px',
color: nfcStatus === 'error' ? '#ef4444' :
nfcStatus === 'success' ? '#10b981' : '#6b7280'
}}>
{nfcMessage}
</p>
)}
{!nfcMessage && (
<p style={{
fontSize: '13px',
color: '#6b7280'
}}>
Write the board URL to an NFC tag for instant access
</p>
)}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span>Copy Link</span>
</>
)}
</div>
)}
{activeTab === 'audio' && (
<div style={{ width: '100%', textAlign: 'center' }}>
<div style={{
fontSize: '48px',
marginBottom: '16px',
opacity: 0.6
}}>
((( )))
</div>
<p style={{
fontSize: '14px',
color: '#374151',
fontWeight: 500,
marginBottom: '8px'
}}>
Audio Connect
</p>
<p style={{
fontSize: '13px',
color: '#6b7280',
marginBottom: '16px'
}}>
Share the board link via ultrasonic audio
</p>
<div style={{
padding: '12px 16px',
backgroundColor: '#fef3c7',
borderRadius: '8px',
border: '1px solid #fcd34d',
fontSize: '12px',
color: '#92400e'
}}>
Coming soon! Audio-based sharing will allow nearby devices to join by listening for an ultrasonic signal.
</div>
</div>
)}
</button>
</div>
</div>
{/* Advanced sharing options (collapsed) */}
<details style={{ marginTop: '4px' }}>
<summary style={{
fontSize: '12px',
color: '#6b7280',
cursor: 'pointer',
userSelect: 'none',
padding: '4px 0',
}}>
More sharing options (NFC, Audio)
</summary>
<div style={{
marginTop: '12px',
display: 'flex',
gap: '8px',
}}>
{/* NFC Button */}
<button
onClick={handleNfcWrite}
disabled={nfcStatus === 'unsupported' || nfcStatus === 'writing'}
style={{
flex: 1,
padding: '12px',
backgroundColor: nfcStatus === 'unsupported' ? '#f3f4f6' :
nfcStatus === 'success' ? '#d1fae5' :
nfcStatus === 'error' ? '#fee2e2' :
nfcStatus === 'writing' ? '#e0e7ff' : 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
cursor: nfcStatus === 'unsupported' || nfcStatus === 'writing' ? 'not-allowed' : 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
opacity: nfcStatus === 'unsupported' ? 0.5 : 1,
}}
>
<span style={{ fontSize: '20px' }}>
{nfcStatus === 'success' ? '✓' : nfcStatus === 'error' ? '!' : '📡'}
</span>
<span style={{ fontSize: '11px', color: '#374151', fontWeight: 500 }}>
{nfcStatus === 'writing' ? 'Writing...' :
nfcStatus === 'success' ? 'Written!' :
nfcStatus === 'unsupported' ? 'NFC Unavailable' :
'Write to NFC'}
</span>
</button>
{/* Audio Button (coming soon) */}
<button
disabled
style={{
flex: 1,
padding: '12px',
backgroundColor: '#f3f4f6',
border: '1px solid #e5e7eb',
borderRadius: '8px',
cursor: 'not-allowed',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
opacity: 0.5,
}}
>
<span style={{ fontSize: '20px' }}>🔊</span>
<span style={{ fontSize: '11px', color: '#374151', fontWeight: 500 }}>
Audio (Soon)
</span>
</button>
</div>
{nfcMessage && (
<p style={{
marginTop: '8px',
fontSize: '11px',
color: nfcStatus === 'error' ? '#ef4444' :
nfcStatus === 'success' ? '#10b981' : '#6b7280',
textAlign: 'center',
}}>
{nfcMessage}
</p>
)}
</details>
</div>
</TldrawUiDialogBody>
<TldrawUiDialogFooter>

View File

@ -9,6 +9,8 @@ import { UserSettingsModal } from "./UserSettingsModal"
import { NetworkGraphPanel } from "../components/networking"
import CryptIDDropdown from "../components/auth/CryptIDDropdown"
import StarBoardButton from "../components/StarBoardButton"
import ShareBoardButton from "../components/ShareBoardButton"
import { SettingsDialog } from "./SettingsDialog"
import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
@ -16,22 +18,69 @@ import {
TldrawUiMenuItem,
useTools,
useActions,
useDialogs,
} from "tldraw"
import { SlidesPanel } from "@/slides/SlidesPanel"
// AI tool model configurations
const AI_TOOLS = [
{ id: 'chat', name: 'Chat', icon: '💬', model: 'llama3.1:8b', provider: 'Ollama', type: 'local' },
{ id: 'make-real', name: 'Make Real', icon: '🔧', model: 'claude-sonnet-4-5', provider: 'Anthropic', type: 'cloud' },
{ id: 'image-gen', name: 'Image Gen', icon: '🎨', model: 'SDXL', provider: 'RunPod', type: 'gpu' },
{ id: 'video-gen', name: 'Video Gen', icon: '🎬', model: 'Wan2.1', provider: 'RunPod', type: 'gpu' },
{ id: 'transcription', name: 'Transcribe', icon: '🎤', model: 'Web Speech', provider: 'Browser', type: 'local' },
{ id: 'mycelial', name: 'Mycelial', icon: '🍄', model: 'llama3.1:70b', provider: 'Ollama', type: 'local' },
];
// Custom SharePanel with layout: CryptID -> Star -> Gear -> Question mark
function CustomSharePanel() {
const tools = useTools()
const actions = useActions()
const { addDialog, removeDialog } = useDialogs()
const [showShortcuts, setShowShortcuts] = React.useState(false)
const [showSettings, setShowSettings] = React.useState(false)
const [showSettingsDropdown, setShowSettingsDropdown] = React.useState(false)
const [showAISection, setShowAISection] = React.useState(false)
const [hasApiKey, setHasApiKey] = React.useState(false)
// ESC key handler for closing dropdowns
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
if (showSettingsDropdown) setShowSettingsDropdown(false)
if (showShortcuts) setShowShortcuts(false)
}
}
if (showSettingsDropdown || showShortcuts) {
// Use capture phase to intercept before tldraw
document.addEventListener('keydown', handleKeyDown, true)
}
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [showSettingsDropdown, showShortcuts])
// Detect dark mode - use state to trigger re-render on change
const [isDarkMode, setIsDarkMode] = React.useState(
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
)
// Check for API keys on mount
React.useEffect(() => {
const checkApiKeys = () => {
const keys = localStorage.getItem('apiKeys')
if (keys) {
try {
const parsed = JSON.parse(keys)
setHasApiKey(!!(parsed.openai || parsed.anthropic || parsed.google))
} catch {
setHasApiKey(false)
}
}
}
checkApiKeys()
}, [])
const handleToggleDarkMode = () => {
const newIsDark = !document.documentElement.classList.contains('dark')
document.documentElement.classList.toggle('dark')
@ -39,6 +88,31 @@ function CustomSharePanel() {
setIsDarkMode(newIsDark)
}
const handleManageApiKeys = () => {
setShowSettingsDropdown(false)
addDialog({
id: "api-keys",
component: ({ onClose: dialogClose }: { onClose: () => void }) => (
<SettingsDialog
onClose={() => {
dialogClose()
removeDialog("api-keys")
// Recheck API keys after dialog closes
const keys = localStorage.getItem('apiKeys')
if (keys) {
try {
const parsed = JSON.parse(keys)
setHasApiKey(!!(parsed.openai || parsed.anthropic || parsed.google))
} catch {
setHasApiKey(false)
}
}
}}
/>
),
})
}
// Helper to extract label string from tldraw label (can be string or {default, menu} object)
const getLabelString = (label: any, fallback: string): string => {
if (typeof label === 'string') return label
@ -147,6 +221,13 @@ function CustomSharePanel() {
<Separator />
{/* Share board button */}
<div style={{ padding: '0 2px' }}>
<ShareBoardButton className="share-panel-btn" />
</div>
<Separator />
{/* Star board button */}
<div style={{ padding: '0 2px' }}>
<StarBoardButton className="share-panel-btn" />
@ -194,27 +275,36 @@ function CustomSharePanel() {
{/* Settings dropdown */}
{showSettingsDropdown && (
<>
{/* Backdrop - only uses onClick, not onPointerDown */}
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 99998,
background: 'transparent',
}}
onClick={() => setShowSettingsDropdown(false)}
/>
{/* Dropdown menu */}
<div
style={{
position: 'absolute',
top: 'calc(100% + 8px)',
right: 0,
minWidth: '200px',
maxHeight: '60vh',
overflowY: 'auto',
overflowX: 'hidden',
background: 'var(--color-panel)',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '8px',
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
zIndex: 99999,
padding: '8px 0',
pointerEvents: 'auto',
}}
onWheel={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{/* Dark mode toggle */}
<button
@ -259,6 +349,103 @@ function CustomSharePanel() {
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '4px 0' }} />
{/* AI Models expandable section */}
<div>
<button
onClick={() => setShowAISection(!showAISection)}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
padding: '10px 16px',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-text)',
fontSize: '13px',
textAlign: 'left',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-muted-2)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'none'
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '16px' }}>🤖</span>
<span>AI Models</span>
</span>
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="currentColor"
style={{ transform: showAISection ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
>
<path d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
{showAISection && (
<div style={{ padding: '8px 16px', backgroundColor: 'var(--color-muted-2)' }}>
<p style={{ fontSize: '10px', color: 'var(--color-text-3)', marginBottom: '8px' }}>
Local models are free. Cloud models require API keys.
</p>
{AI_TOOLS.map((tool) => (
<div
key={tool.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 0',
borderBottom: '1px solid var(--color-panel-contrast)',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: 'var(--color-text)' }}>
<span>{tool.icon}</span>
<span>{tool.name}</span>
</span>
<span
style={{
fontSize: '9px',
padding: '2px 6px',
borderRadius: '10px',
backgroundColor: tool.type === 'local' ? '#d1fae5' : tool.type === 'gpu' ? '#e0e7ff' : '#fef3c7',
color: tool.type === 'local' ? '#065f46' : tool.type === 'gpu' ? '#3730a3' : '#92400e',
fontWeight: 500,
}}
>
{tool.model}
</span>
</div>
))}
<button
onClick={handleManageApiKeys}
style={{
width: '100%',
marginTop: '8px',
padding: '6px 10px',
fontSize: '11px',
fontWeight: 500,
backgroundColor: 'var(--color-primary, #3b82f6)',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
{hasApiKey ? 'Manage API Keys' : 'Add API Keys'}
</button>
</div>
)}
</div>
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '4px 0' }} />
{/* All settings */}
<button
onClick={() => {
@ -341,29 +528,36 @@ function CustomSharePanel() {
{/* Keyboard shortcuts panel */}
{showShortcuts && (
<>
{/* Backdrop - only uses onClick, not onPointerDown */}
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 99998,
background: 'transparent',
}}
onClick={() => setShowShortcuts(false)}
/>
{/* Shortcuts menu */}
<div
style={{
position: 'absolute',
top: 'calc(100% + 8px)',
right: 0,
width: '320px',
maxHeight: '70vh',
maxHeight: '60vh',
overflowY: 'auto',
overflowX: 'hidden',
background: 'var(--color-panel)',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '8px',
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
zIndex: 99999,
padding: '12px 0',
pointerEvents: 'auto',
}}
onWheel={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<div style={{
padding: '8px 16px 12px',

View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/open-mapping/**/*"],
"exclude": []
}

View File

@ -175,6 +175,150 @@ export class AutomergeDurableObject {
},
})
})
// =============================================================================
// Version History API
// =============================================================================
.get("/room/:roomId/history", async (request) => {
// Initialize roomId if not already set
if (!this.roomId) {
await this.ctx.blockConcurrencyWhile(async () => {
await this.ctx.storage.put("roomId", request.params.roomId)
this.roomId = request.params.roomId
})
}
// Ensure sync manager is initialized
if (!this.syncManager) {
this.syncManager = new AutomergeSyncManager(this.r2, this.roomId!)
await this.syncManager.initialize()
}
const history = await this.syncManager.getHistory()
return new Response(JSON.stringify({ history }), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
})
})
.get("/room/:roomId/snapshot/:hash", async (request) => {
// Initialize roomId if not already set
if (!this.roomId) {
await this.ctx.blockConcurrencyWhile(async () => {
await this.ctx.storage.put("roomId", request.params.roomId)
this.roomId = request.params.roomId
})
}
// Ensure sync manager is initialized
if (!this.syncManager) {
this.syncManager = new AutomergeSyncManager(this.r2, this.roomId!)
await this.syncManager.initialize()
}
const hash = request.params.hash
const snapshot = await this.syncManager.getSnapshotAtHash(hash)
if (!snapshot) {
return new Response(JSON.stringify({ error: "Snapshot not found" }), {
status: 404,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
},
})
}
return new Response(JSON.stringify({ snapshot }), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
})
})
.post("/room/:roomId/diff", async (request) => {
// Initialize roomId if not already set
if (!this.roomId) {
await this.ctx.blockConcurrencyWhile(async () => {
await this.ctx.storage.put("roomId", request.params.roomId)
this.roomId = request.params.roomId
})
}
// Ensure sync manager is initialized
if (!this.syncManager) {
this.syncManager = new AutomergeSyncManager(this.r2, this.roomId!)
await this.syncManager.initialize()
}
const { fromHash, toHash } = (await request.json()) as { fromHash: string | null; toHash: string | null }
const diff = await this.syncManager.getDiff(fromHash, toHash)
if (!diff) {
return new Response(JSON.stringify({ error: "Could not compute diff" }), {
status: 500,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
},
})
}
return new Response(JSON.stringify({ diff }), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
})
})
.post("/room/:roomId/revert", async (request) => {
// Initialize roomId if not already set
if (!this.roomId) {
await this.ctx.blockConcurrencyWhile(async () => {
await this.ctx.storage.put("roomId", request.params.roomId)
this.roomId = request.params.roomId
})
}
// Ensure sync manager is initialized
if (!this.syncManager) {
this.syncManager = new AutomergeSyncManager(this.r2, this.roomId!)
await this.syncManager.initialize()
}
const { hash } = (await request.json()) as { hash: string }
const success = await this.syncManager.revertToHash(hash)
if (!success) {
return new Response(JSON.stringify({ error: "Could not revert to version" }), {
status: 500,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
},
})
}
// Broadcast the revert to all connected clients
const snapshot = await this.syncManager.getDocumentJson()
this.broadcastToAll({ type: "full_sync", store: snapshot?.store || {} })
return new Response(JSON.stringify({ success: true }), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
})
})
// `fetch` is the entry point for all requests to the Durable Object
fetch(request: Request): Response | Promise<Response> {
@ -592,6 +736,23 @@ export class AutomergeDurableObject {
}
}
private broadcastToAll(message: any) {
// Broadcast JSON messages to ALL connected clients (for system events like revert)
let broadcastCount = 0
for (const [sessionId, client] of this.clients) {
if (client.readyState === WebSocket.OPEN) {
try {
console.log(`🔌 Worker: Broadcasting to ${sessionId}`)
client.send(JSON.stringify(message))
broadcastCount++
} catch (error) {
console.error(`❌ Worker: Error broadcasting to ${sessionId}:`, error)
}
}
}
console.log(`🔌 Worker: Broadcast message to ALL ${broadcastCount} client(s)`)
}
// Generate a fast hash of the document state for change detection
// OPTIMIZED: Instead of JSON.stringify on entire document (expensive for large docs),
// we hash based on record IDs, types, and metadata only

View File

@ -373,4 +373,209 @@ export class AutomergeSyncManager {
}
}
}
// =============================================================================
// Version History Methods
// =============================================================================
/**
* Get the change history of the document
* Returns a list of changes with timestamps and summaries
*/
async getHistory(): Promise<HistoryEntry[]> {
await this.initialize()
if (!this.doc) {
return []
}
try {
// Get all changes from the document
const changes = Automerge.getAllChanges(this.doc)
const history: HistoryEntry[] = []
for (const change of changes) {
try {
// Decode the change to get metadata
const decoded = Automerge.decodeChange(change)
history.push({
hash: decoded.hash,
timestamp: decoded.time ? new Date(decoded.time * 1000).toISOString() : null,
message: decoded.message || null,
actor: decoded.actor,
})
} catch (e) {
console.warn('Failed to decode change:', e)
}
}
// Sort by timestamp (newest first)
history.sort((a, b) => {
if (!a.timestamp && !b.timestamp) return 0
if (!a.timestamp) return 1
if (!b.timestamp) return -1
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
})
return history
} catch (error) {
console.error('Error getting history:', error)
return []
}
}
/**
* Get a snapshot of the document at a specific point in history
* @param hash - The change hash to view from
*/
async getSnapshotAtHash(hash: string): Promise<TLStoreSnapshot | null> {
await this.initialize()
if (!this.doc) {
return null
}
try {
// Get all changes and find the index of the target hash
const changes = Automerge.getAllChanges(this.doc)
let targetIndex = -1
for (let i = 0; i < changes.length; i++) {
const decoded = Automerge.decodeChange(changes[i])
if (decoded.hash === hash) {
targetIndex = i
break
}
}
if (targetIndex === -1) {
console.error('Change hash not found:', hash)
return null
}
// Rebuild the document up to that point
let historicalDoc = Automerge.init<TLStoreSnapshot>()
for (let i = 0; i <= targetIndex; i++) {
const [newDoc] = Automerge.applyChanges(historicalDoc, [changes[i]])
historicalDoc = newDoc
}
return JSON.parse(JSON.stringify(historicalDoc)) as TLStoreSnapshot
} catch (error) {
console.error('Error getting snapshot at hash:', error)
return null
}
}
/**
* Get the diff between two snapshots
* Returns added, removed, and modified records
*/
async getDiff(fromHash: string | null, toHash: string | null): Promise<SnapshotDiff | null> {
await this.initialize()
if (!this.doc) {
return null
}
try {
// Get the "from" snapshot (or empty if null)
const fromSnapshot = fromHash
? await this.getSnapshotAtHash(fromHash)
: { store: {} }
// Get the "to" snapshot (or current if null)
const toSnapshot = toHash
? await this.getSnapshotAtHash(toHash)
: JSON.parse(JSON.stringify(this.doc)) as TLStoreSnapshot
if (!fromSnapshot || !toSnapshot) {
return null
}
const fromStore = fromSnapshot.store || {}
const toStore = toSnapshot.store || {}
const added: Record<string, any> = {}
const removed: Record<string, any> = {}
const modified: Record<string, { before: any; after: any }> = {}
// Find added and modified records
for (const [id, record] of Object.entries(toStore)) {
if (!(id in fromStore)) {
added[id] = record
} else if (JSON.stringify(fromStore[id]) !== JSON.stringify(record)) {
modified[id] = {
before: fromStore[id],
after: record,
}
}
}
// Find removed records
for (const [id, record] of Object.entries(fromStore)) {
if (!(id in toStore)) {
removed[id] = record
}
}
return { added, removed, modified }
} catch (error) {
console.error('Error getting diff:', error)
return null
}
}
/**
* Revert the document to a specific point in history
* This creates a new change that sets the document state to match the historical state
* @param hash - The change hash to revert to
*/
async revertToHash(hash: string): Promise<boolean> {
await this.initialize()
if (!this.doc) {
return false
}
try {
const historicalSnapshot = await this.getSnapshotAtHash(hash)
if (!historicalSnapshot) {
console.error('Could not get historical snapshot for hash:', hash)
return false
}
// Apply the historical state as a new change
this.doc = Automerge.change(this.doc, `Revert to ${hash.slice(0, 8)}`, (doc) => {
doc.store = historicalSnapshot.store || {}
})
// Save to R2
await this.forceSave()
console.log(`✅ Reverted document to hash ${hash.slice(0, 8)}`)
return true
} catch (error) {
console.error('Error reverting to hash:', error)
return false
}
}
}
// =============================================================================
// History Types
// =============================================================================
export interface HistoryEntry {
hash: string
timestamp: string | null
message: string | null
actor: string
}
export interface SnapshotDiff {
added: Record<string, any>
removed: Record<string, any>
modified: Record<string, { before: any; after: any }>
}

View File

@ -1,4 +1,4 @@
import { Environment, Board, BoardPermission, PermissionLevel, PermissionCheckResult, User } from './types';
import { Environment, Board, BoardPermission, PermissionLevel, PermissionCheckResult, User, BoardAccessToken } from './types';
// Generate a UUID v4
function generateUUID(): string {
@ -7,22 +7,37 @@ function generateUUID(): string {
/**
* Get a user's effective permission for a board
* Priority: explicit permission > board owner (admin) > default permission
* Priority: access token > explicit permission > board owner (admin) > default permission
*
* @param accessToken - Optional access token from share link (grants specific permission)
*/
export async function getEffectivePermission(
db: D1Database,
boardId: string,
userId: string | null
userId: string | null,
accessToken?: 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 an access token is provided, validate it and use its permission level
if (accessToken) {
const tokenPermission = await validateAccessToken(db, boardId, accessToken);
if (tokenPermission) {
return {
permission: tokenPermission,
isOwner: false,
boardExists: !!board,
grantedByToken: true
};
}
}
// Board doesn't exist in permissions DB
// Anonymous users get VIEW by default, authenticated users can edit
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,
@ -30,10 +45,11 @@ export async function getEffectivePermission(
};
}
// If user is not authenticated, return default permission
// If user is not authenticated, return VIEW (secure by default)
// To grant edit access to anonymous users, they must use a share link with access token
if (!userId) {
return {
permission: board.default_permission as PermissionLevel,
permission: 'view',
isOwner: false,
boardExists: true
};
@ -127,6 +143,7 @@ export async function ensureBoardExists(
/**
* GET /boards/:boardId/permission
* Get current user's permission for a board
* Query params: ?token=<access_token> - optional access token from share link
*/
export async function handleGetPermission(
boardId: string,
@ -136,9 +153,9 @@ export async function handleGetPermission(
try {
const db = env.CRYPTID_DB;
if (!db) {
// No database - default to edit for backwards compatibility
// No database - default to view for anonymous (secure by default)
return new Response(JSON.stringify({
permission: 'edit',
permission: 'view',
isOwner: false,
boardExists: false,
message: 'Permission system not configured'
@ -161,7 +178,11 @@ export async function handleGetPermission(
}
}
const result = await getEffectivePermission(db, boardId, userId);
// Get access token from query params if provided
const url = new URL(request.url);
const accessToken = url.searchParams.get('token');
const result = await getEffectivePermission(db, boardId, userId, accessToken);
return new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' },
@ -579,3 +600,304 @@ export async function handleUpdateBoard(
});
}
}
// =============================================================================
// Access Token Functions
// =============================================================================
/**
* Generate a cryptographically secure random token
*/
function generateAccessToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Validate an access token and return the permission level if valid
*/
export async function validateAccessToken(
db: D1Database,
boardId: string,
token: string
): Promise<PermissionLevel | null> {
const accessToken = await db.prepare(`
SELECT * FROM board_access_tokens
WHERE board_id = ? AND token = ? AND is_active = 1
`).bind(boardId, token).first<BoardAccessToken>();
if (!accessToken) {
return null;
}
// Check expiration
if (accessToken.expires_at) {
const expiresAt = new Date(accessToken.expires_at);
if (expiresAt < new Date()) {
return null;
}
}
// Check max uses
if (accessToken.max_uses !== null && accessToken.use_count >= accessToken.max_uses) {
return null;
}
// Increment use count
await db.prepare(`
UPDATE board_access_tokens SET use_count = use_count + 1 WHERE id = ?
`).bind(accessToken.id).run();
return accessToken.permission;
}
/**
* POST /boards/:boardId/access-tokens
* Create a new access token (admin only)
* Body: { permission, label?, expiresIn?, maxUses? }
*/
export async function handleCreateAccessToken(
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 {
permission: PermissionLevel;
label?: string;
expiresIn?: number; // seconds from now
maxUses?: number;
};
if (!body.permission || !['view', 'edit', 'admin'].includes(body.permission)) {
return new Response(JSON.stringify({ error: 'Invalid permission level' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Ensure board exists
await ensureBoardExists(db, boardId, deviceKey.user_id);
const token = generateAccessToken();
const id = generateUUID();
// Calculate expiration
let expiresAt: string | null = null;
if (body.expiresIn) {
const expDate = new Date(Date.now() + body.expiresIn * 1000);
expiresAt = expDate.toISOString();
}
await db.prepare(`
INSERT INTO board_access_tokens (id, board_id, token, permission, created_by, expires_at, max_uses, label, use_count, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 1)
`).bind(
id,
boardId,
token,
body.permission,
deviceKey.user_id,
expiresAt,
body.maxUses || null,
body.label || null
).run();
return new Response(JSON.stringify({
success: true,
token,
id,
permission: body.permission,
expiresAt,
maxUses: body.maxUses || null,
label: body.label || null
}), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Create access token error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
/**
* GET /boards/:boardId/access-tokens
* List all access tokens for a board (admin only)
*/
export async function handleListAccessTokens(
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 tokens = await db.prepare(`
SELECT id, board_id, permission, created_at, expires_at, max_uses, use_count, is_active, label
FROM board_access_tokens
WHERE board_id = ?
ORDER BY created_at DESC
`).bind(boardId).all<Omit<BoardAccessToken, 'token' | 'created_by'>>();
return new Response(JSON.stringify({
tokens: tokens.results || []
}), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('List access tokens error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
/**
* DELETE /boards/:boardId/access-tokens/:tokenId
* Revoke an access token (admin only)
*/
export async function handleRevokeAccessToken(
boardId: string,
tokenId: 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' },
});
}
// Deactivate the token (soft delete)
await db.prepare(`
UPDATE board_access_tokens SET is_active = 0 WHERE id = ? AND board_id = ?
`).bind(tokenId, boardId).run();
return new Response(JSON.stringify({
success: true,
message: 'Access token revoked'
}), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Revoke access token error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View File

@ -12,7 +12,7 @@ function generateUUID(): string {
return crypto.randomUUID();
}
// Send email via SendGrid
// Send email via Resend
async function sendEmail(
env: Environment,
to: string,
@ -20,24 +20,33 @@ async function sendEmail(
htmlContent: string
): Promise<boolean> {
try {
const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
if (!env.RESEND_API_KEY) {
console.error('RESEND_API_KEY not configured');
return false;
}
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.SENDGRID_API_KEY}`,
'Authorization': `Bearer ${env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
personalizations: [{ to: [{ email: to }] }],
from: { email: env.CRYPTID_EMAIL_FROM || 'noreply@jeffemmett.com', name: 'CryptID' },
from: env.CRYPTID_EMAIL_FROM || 'CryptID <noreply@jeffemmett.com>',
to: [to],
subject,
content: [{ type: 'text/html', value: htmlContent }],
html: htmlContent,
}),
});
if (!response.ok) {
console.error('SendGrid error:', await response.text());
const errorText = await response.text();
console.error('Resend error:', errorText);
return false;
}
const result = await response.json() as { id?: string };
console.log('Email sent successfully, id:', result.id);
return true;
} catch (error) {
console.error('Email send error:', error);
@ -448,6 +457,174 @@ export async function handleLinkDevice(
}
}
/**
* Send a backup email to set up account on another device
* POST /api/auth/send-backup-email
* Body: { email, username }
*
* This is called during registration when the user provides an email.
* It sends an email with a link to set up their account on another device.
*/
export async function handleSendBackupEmail(
request: Request,
env: Environment
): Promise<Response> {
try {
const body = await request.json() as {
email: string;
username: string;
};
const { email, username } = body;
if (!email || !username) {
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return new Response(JSON.stringify({ error: 'Invalid email format' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const db = env.CRYPTID_DB;
if (!db) {
// If no DB, still try to send email (graceful degradation)
console.log('No database configured, attempting to send email directly');
}
// If DB exists, create/update user record
if (db) {
// Check if user already exists
const existingUser = await db.prepare(
'SELECT * FROM users WHERE cryptid_username = ?'
).bind(username).first<User>();
if (existingUser) {
// Update email if user exists
await db.prepare(
"UPDATE users SET email = ?, updated_at = datetime('now') WHERE cryptid_username = ?"
).bind(email, username).run();
} else {
// Create new user record
const userId = crypto.randomUUID();
await db.prepare(
'INSERT INTO users (id, email, cryptid_username, email_verified) VALUES (?, ?, ?, 0)'
).bind(userId, email, username).run();
}
}
// Send the backup email
const appUrl = env.APP_URL || 'https://jeffemmett.com';
const setupUrl = `${appUrl}/setup-device?username=${encodeURIComponent(username)}`;
const emailSent = await sendEmail(
env,
email,
`Set up CryptID "${username}" on another device`,
`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { text-align: center; margin-bottom: 30px; }
.icon { font-size: 48px; margin-bottom: 10px; }
h1 { color: #8b5cf6; margin: 0 0 10px 0; }
.card { background: #f9fafb; border-radius: 12px; padding: 24px; margin: 20px 0; }
.step { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 16px; }
.step-number { background: #8b5cf6; color: white; width: 24px; height: 24px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 600; flex-shrink: 0; }
.button { display: inline-block; padding: 14px 28px; background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%); color: white; text-decoration: none; border-radius: 10px; font-weight: 600; margin: 20px 0; }
.footer { color: #666; font-size: 12px; text-align: center; margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; }
.warning { background: #fef3c7; border-radius: 8px; padding: 12px 16px; margin: 20px 0; color: #92400e; font-size: 13px; }
</style>
</head>
<body>
<div class="header">
<div class="icon">🔐</div>
<h1>Welcome to CryptID</h1>
<p>Your passwordless account is ready!</p>
</div>
<p>Hi <strong>${username}</strong>,</p>
<p>Your CryptID account has been created on your current device. To access your account from another device (like your phone), follow these steps:</p>
<div class="card">
<div class="step">
<span class="step-number">1</span>
<div>
<strong>Open the link below on your other device</strong>
<p style="margin: 4px 0 0 0; color: #666; font-size: 14px;">Use your phone, tablet, or another computer</p>
</div>
</div>
<div class="step">
<span class="step-number">2</span>
<div>
<strong>A new cryptographic key will be generated</strong>
<p style="margin: 4px 0 0 0; color: #666; font-size: 14px;">This key is unique to that device and stored securely in the browser</p>
</div>
</div>
<div class="step">
<span class="step-number">3</span>
<div>
<strong>You're set! Both devices can now access your account</strong>
<p style="margin: 4px 0 0 0; color: #666; font-size: 14px;">No passwords to remember - your browser handles authentication</p>
</div>
</div>
</div>
<div style="text-align: center;">
<a href="${setupUrl}" class="button">Set Up on Another Device</a>
</div>
<div class="warning">
<strong> Security Note:</strong> Only open this link on devices you own. Each device that accesses your account stores a unique cryptographic key.
</div>
<div class="footer">
<p>This email was sent because you created a CryptID account and opted for multi-device backup.</p>
<p>If you didn't request this, you can safely ignore this email.</p>
<p style="margin-top: 16px;">CryptID by <a href="${appUrl}" style="color: #8b5cf6;">jeffemmett.com</a></p>
</div>
</body>
</html>
`
);
if (emailSent) {
return new Response(JSON.stringify({
success: true,
message: 'Backup email sent successfully',
}), {
headers: { 'Content-Type': 'application/json' },
});
} else {
return new Response(JSON.stringify({
error: 'Failed to send email',
message: 'Please check your email address and try again',
}), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
} catch (error) {
console.error('Send backup email error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
/**
* Check if a public key is linked to an account
* POST /auth/lookup

View File

@ -88,6 +88,29 @@ 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);
-- Access tokens for shareable links with specific permission levels
-- Anonymous users can use these tokens to get edit/admin access without authentication
CREATE TABLE IF NOT EXISTS board_access_tokens (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL,
token TEXT NOT NULL UNIQUE, -- Random hex token (64 chars)
permission TEXT NOT NULL CHECK (permission IN ('view', 'edit', 'admin')),
created_by TEXT NOT NULL, -- User ID who created the token
created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT, -- NULL = never expires
max_uses INTEGER, -- NULL = unlimited
use_count INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1, -- 1 = active, 0 = revoked
label TEXT, -- Optional label for identification
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
-- Access token indexes
CREATE INDEX IF NOT EXISTS idx_access_tokens_board ON board_access_tokens(board_id);
CREATE INDEX IF NOT EXISTS idx_access_tokens_token ON board_access_tokens(token);
CREATE INDEX IF NOT EXISTS idx_access_tokens_active ON board_access_tokens(board_id, is_active);
-- =============================================================================
-- User Networking / Social Graph System
-- =============================================================================

View File

@ -10,7 +10,7 @@ export interface Environment {
DAILY_DOMAIN: string;
// CryptID auth bindings
CRYPTID_DB?: D1Database;
SENDGRID_API_KEY?: string;
RESEND_API_KEY?: string;
CRYPTID_EMAIL_FROM?: string;
APP_URL?: string;
}
@ -95,6 +95,25 @@ export interface PermissionCheckResult {
permission: PermissionLevel;
isOwner: boolean;
boardExists: boolean;
grantedByToken?: boolean; // True if permission was granted via access token
}
/**
* Access token for sharing boards with specific permissions
* Stored in board_access_tokens table
*/
export interface BoardAccessToken {
id: string;
board_id: string;
token: string; // Random token string
permission: PermissionLevel;
created_by: string; // User ID who created the token
created_at: string;
expires_at: string | null; // NULL = never expires
max_uses: number | null; // NULL = unlimited
use_count: number;
is_active: number; // SQLite boolean (0 or 1)
label: string | null; // Optional label for the token
}
// =============================================================================

View File

@ -23,7 +23,13 @@ import {
handleGrantPermission,
handleRevokePermission,
handleUpdateBoard,
handleCreateAccessToken,
handleListAccessTokens,
handleRevokeAccessToken,
} from "./boardPermissions"
import {
handleSendBackupEmail,
} from "./cryptidAuth"
// make sure our sync durable objects are made available to cloudflare
export { AutomergeDurableObject } from "./AutomergeDurableObject"
@ -826,6 +832,13 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
}
})
// =============================================================================
// CryptID Auth API
// =============================================================================
// Send backup email for multi-device setup
.post("/api/auth/send-backup-email", handleSendBackupEmail)
// =============================================================================
// User Networking / Social Graph API
// =============================================================================
@ -877,6 +890,19 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
.patch("/boards/:boardId", (req, env) =>
handleUpdateBoard(req.params.boardId, req, env))
// Access token endpoints for shareable links
// Create a new access token (admin only)
.post("/boards/:boardId/access-tokens", (req, env) =>
handleCreateAccessToken(req.params.boardId, req, env))
// List all access tokens for a board (admin only)
.get("/boards/:boardId/access-tokens", (req, env) =>
handleListAccessTokens(req.params.boardId, req, env))
// Revoke an access token (admin only)
.delete("/boards/:boardId/access-tokens/:tokenId", (req, env) =>
handleRevokeAccessToken(req.params.boardId, req.params.tokenId, req, env))
async function backupAllBoards(env: Environment) {
try {
// List all room files from TLDRAW_BUCKET

View File

@ -16,6 +16,7 @@ upstream_protocol = "https"
[dev.miniflare]
kv_persist = true
r2_persist = true
d1_persist = true
durable_objects_persist = true
[durable_objects]
@ -37,6 +38,10 @@ binding = 'BOARD_BACKUPS_BUCKET'
bucket_name = 'board-backups-preview'
preview_bucket_name = 'board-backups-preview'
[[d1_databases]]
binding = "CRYPTID_DB"
database_name = "cryptid-auth-dev"
database_id = "35fbe755-0e7c-4b9a-a454-34f945e5f7cc"
[observability]
enabled = true