From 195cc7f86ec55bf7611d4643965719f09cfd9e9b Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 10 Dec 2025 14:21:50 -0800 Subject: [PATCH] feat: add version history, Resend email, CryptID registration flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package-lock.json | 20 +- src/components/ShareBoardButton.tsx | 55 +- src/components/StarBoardButton.tsx | 76 +- src/components/auth/CryptID.tsx | 901 ++++++++++++++---- src/components/auth/CryptIDDropdown.tsx | 736 +++++++++++++- .../history/VersionHistoryPanel.tsx | 631 ++++++++++++ src/components/history/index.ts | 3 + src/components/history/useVersionHistory.ts | 103 ++ .../networking/NetworkGraphMinimap.tsx | 65 +- .../networking/NetworkGraphPanel.tsx | 25 +- src/components/networking/useNetworkGraph.ts | 9 +- src/context/AuthContext.tsx | 66 +- src/hooks/useMaximize.ts | 4 +- src/lib/auth/types.ts | 1 + src/lib/networking/connectionService.ts | 36 +- src/shapes/ImageGenShapeUtil.tsx | 47 +- src/shapes/MapShapeUtil.tsx | 442 ++++++++- src/ui/CustomToolbar.tsx | 792 +-------------- src/ui/InviteDialog.tsx | 463 ++++----- src/ui/components.tsx | 196 +++- tsconfig.open-mapping.json | 8 + worker/AutomergeDurableObject.ts | 161 ++++ worker/automerge-sync-manager.ts | 205 ++++ worker/boardPermissions.ts | 344 ++++++- worker/cryptidAuth.ts | 191 +++- worker/schema.sql | 23 + worker/types.ts | 21 +- worker/worker.ts | 26 + wrangler.dev.toml | 5 + 29 files changed, 4360 insertions(+), 1295 deletions(-) create mode 100644 src/components/history/VersionHistoryPanel.tsx create mode 100644 src/components/history/index.ts create mode 100644 src/components/history/useVersionHistory.ts create mode 100644 tsconfig.open-mapping.json diff --git a/package-lock.json b/package-lock.json index 1031623..200c5d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/components/ShareBoardButton.tsx b/src/components/ShareBoardButton.tsx index b647b4e..2d8501b 100644 --- a/src/components/ShareBoardButton.tsx +++ b/src/components/ShareBoardButton.tsx @@ -30,6 +30,53 @@ const ShareBoardButton: React.FC = ({ 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 ( + + ); + } + + // Full button version for other contexts (toolbar, etc.) return ( ); }; diff --git a/src/components/StarBoardButton.tsx b/src/components/StarBoardButton.tsx index 25fa391..33d57a6 100644 --- a/src/components/StarBoardButton.tsx +++ b/src/components/StarBoardButton.tsx @@ -75,9 +75,75 @@ const StarBoardButton: React.FC = ({ 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 ( +
+ + + {/* Custom popup notification */} + {showPopup && ( +
+ {popupMessage} +
+ )} +
+ ); } return ( @@ -86,14 +152,14 @@ const StarBoardButton: React.FC = ({ 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 ? ( ) : ( - + {isStarred ? ( ) : ( diff --git a/src/components/auth/CryptID.tsx b/src/components/auth/CryptID.tsx index f2f8e76..bedcccf 100644 --- a/src/components/auth/CryptID.tsx +++ b/src/components/auth/CryptID.tsx @@ -3,29 +3,36 @@ import { CryptoAuthService } from '../../lib/auth/cryptoAuthService'; import { useAuth } from '../../context/AuthContext'; import { useNotifications } from '../../context/NotificationContext'; import { checkBrowserSupport, isSecureContext } from '../../lib/utils/browser'; +import { WORKER_URL } from '../../constants/workerUrl'; interface CryptIDProps { onSuccess?: () => void; onCancel?: () => void; } +type RegistrationStep = 'welcome' | 'username' | 'email' | 'success'; + /** * CryptID - WebCryptoAPI-based authentication component + * Enhanced with multi-step registration and email backup */ const CryptID: React.FC = ({ onSuccess, onCancel }) => { const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); const [isRegistering, setIsRegistering] = useState(false); + const [registrationStep, setRegistrationStep] = useState('welcome'); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); const [existingUsers, setExistingUsers] = useState([]); const [suggestedUsername, setSuggestedUsername] = useState(''); + const [emailSent, setEmailSent] = useState(false); const [browserSupport, setBrowserSupport] = useState<{ supported: boolean; secure: boolean; webcrypto: boolean; }>({ supported: false, secure: false, webcrypto: false }); - - const { setSession } = useAuth(); + + const { setSession, updateSession } = useAuth(); const { addNotification } = useNotifications(); // Check browser support and existing users on mount @@ -33,12 +40,12 @@ const CryptID: React.FC = ({ onSuccess, onCancel }) => { const checkSupport = () => { const supported = checkBrowserSupport(); const secure = isSecureContext(); - const webcrypto = typeof window !== 'undefined' && - typeof window.crypto !== 'undefined' && + const webcrypto = typeof window !== 'undefined' && + typeof window.crypto !== 'undefined' && typeof window.crypto.subtle !== 'undefined'; - + setBrowserSupport({ supported, secure, webcrypto }); - + if (!supported) { setError('Your browser does not support the required features for cryptographic authentication.'); addNotification('Browser not supported for cryptographic authentication', 'warning'); @@ -50,230 +57,800 @@ const CryptID: React.FC = ({ onSuccess, onCancel }) => { addNotification('WebCryptoAPI not available', 'warning'); } }; - + const checkExistingUsers = () => { try { - // Get registered users from localStorage const users = JSON.parse(localStorage.getItem('registeredUsers') || '[]'); - - // Filter users to only include those with valid authentication keys + const validUsers = users.filter((user: string) => { - // Check if public key exists const publicKey = localStorage.getItem(`${user}_publicKey`); if (!publicKey) return false; - - // Check if authentication data exists + const authData = localStorage.getItem(`${user}_authData`); if (!authData) return false; - - // Verify the auth data is valid JSON and has required fields + try { const parsed = JSON.parse(authData); return parsed.challenge && parsed.signature && parsed.timestamp; } catch (e) { - console.warn(`Invalid auth data for user ${user}:`, e); return false; } }); - + setExistingUsers(validUsers); - - // If there are valid users, suggest the first one for login + if (validUsers.length > 0) { setSuggestedUsername(validUsers[0]); - setUsername(validUsers[0]); // Pre-fill the username field - setIsRegistering(false); // Default to login mode if users exist + setUsername(validUsers[0]); + setIsRegistering(false); } else { - setIsRegistering(true); // Default to registration mode if no users exist - } - - // Log for debugging - if (users.length !== validUsers.length) { - console.log(`Found ${users.length} registered users, but only ${validUsers.length} have valid keys`); + setIsRegistering(true); + setRegistrationStep('welcome'); } } catch (error) { console.error('Error checking existing users:', error); setExistingUsers([]); } }; - + checkSupport(); checkExistingUsers(); }, [addNotification]); /** - * Handle form submission for both login and registration + * Send backup email with magic link */ - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(null); + const sendBackupEmail = async (userEmail: string, userName: string) => { setIsLoading(true); - try { - if (!browserSupport.supported || !browserSupport.secure || !browserSupport.webcrypto) { - setError('Browser does not support cryptographic authentication'); - setIsLoading(false); - return; - } + // Call the Worker API to send backup email + const response = await fetch(`${WORKER_URL}/api/auth/send-backup-email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: userEmail, username: userName }), + }); - if (isRegistering) { - // Registration flow using CryptoAuthService - const result = await CryptoAuthService.register(username); - if (result.success && result.session) { - setSession(result.session); - if (onSuccess) onSuccess(); - } else { - setError(result.error || 'Registration failed'); - addNotification('Registration failed. Please try again.', 'error'); - } + if (response.ok) { + setEmailSent(true); + // Update session with email + updateSession({ email: userEmail, backupCreated: true }); + addNotification('Backup email sent! Check your inbox.', 'success'); } else { - // Login flow using CryptoAuthService - const result = await CryptoAuthService.login(username); - if (result.success && result.session) { - setSession(result.session); - if (onSuccess) onSuccess(); - } else { - setError(result.error || 'User not found or authentication failed'); - addNotification('Login failed. Please check your username.', 'error'); - } + const data = await response.json() as { error?: string }; + throw new Error(data.error || 'Failed to send email'); } } catch (err) { - console.error('Cryptographic authentication error:', err); - setError('An unexpected error occurred during authentication'); - addNotification('Authentication error. Please try again later.', 'error'); + console.error('Failed to send backup email:', err); + // Don't block registration if email fails + addNotification('Could not send backup email, but your account is created.', 'warning'); } finally { setIsLoading(false); } }; - if (!browserSupport.supported) { + /** + * Handle registration + */ + const handleRegister = async () => { + setError(null); + setIsLoading(true); + + try { + const result = await CryptoAuthService.register(username); + if (result.success && result.session) { + setSession(result.session); + + // Move to email step if email provided, otherwise success + if (email) { + await sendBackupEmail(email, username); + } + setRegistrationStep('success'); + } else { + setError(result.error || 'Registration failed'); + addNotification('Registration failed. Please try again.', 'error'); + } + } catch (err) { + console.error('Registration error:', err); + setError('An unexpected error occurred during registration'); + } finally { + setIsLoading(false); + } + }; + + /** + * Handle login + */ + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsLoading(true); + + try { + const result = await CryptoAuthService.login(username); + if (result.success && result.session) { + setSession(result.session); + if (onSuccess) onSuccess(); + } else { + setError(result.error || 'User not found or authentication failed'); + addNotification('Login failed. Please check your username.', 'error'); + } + } catch (err) { + console.error('Login error:', err); + setError('An unexpected error occurred during authentication'); + } finally { + setIsLoading(false); + } + }; + + // Browser not supported + if (!browserSupport.supported || !browserSupport.secure) { return ( -
-

Browser Not Supported

-

Your browser does not support the required features for cryptographic authentication.

-

Please use a modern browser with WebCryptoAPI support.

- {onCancel && ( - - )} +
+
+
⚠️
+

+ {!browserSupport.supported ? 'Browser Not Supported' : 'Secure Connection Required'} +

+

+ {!browserSupport.supported + ? 'Your browser does not support the required features for cryptographic authentication. Please use a modern browser.' + : 'CryptID requires a secure connection (HTTPS) to protect your cryptographic keys.'} +

+ {onCancel && ( + + )} +
); } - if (!browserSupport.secure) { + // Registration flow + if (isRegistering) { return ( -
-

Secure Context Required

-

Cryptographic authentication requires a secure context (HTTPS).

-

Please access this application over HTTPS.

- {onCancel && ( - - )} -
- ); - } +
+ {/* Step indicator */} +
+ {['welcome', 'username', 'email', 'success'].map((step, index) => ( + +
index ? '#22c55e' : '#e5e7eb' + }}> + {['welcome', 'username', 'email', 'success'].indexOf(registrationStep) > index ? '✓' : index + 1} +
+ {index < 3 &&
} + + ))} +
- return ( -
-

{isRegistering ? 'Create CryptID Account' : 'CryptID Sign In'}

- - {/* Show existing users if available */} - {existingUsers.length > 0 && !isRegistering && ( -
-

Available Accounts with Valid Keys

-
- {existingUsers.map((user) => ( + {/* Welcome Step */} + {registrationStep === 'welcome' && ( +
+
🔐
+

Welcome to CryptID

+

Passwordless, secure authentication

+ +
+

How does passwordless login work?

+
+
+ 🔑 +
+ Cryptographic Keys +

+ When you create an account, your browser generates a unique cryptographic key pair. + The private key never leaves your device. +

+
+
+
+ 💾 +
+ Secure Storage +

+ Your keys are stored securely in your browser using WebCryptoAPI - + the same technology used by banks and governments. +

+
+
+
+ 📱 +
+ Multi-Device Access +

+ Add your email to receive a backup link. Open it on another device + (like your phone) to sync your account securely. +

+
+
+
+
+ +
+
+ No password to remember or lose +
+
+ Phishing-resistant authentication +
+
+ Your data stays encrypted +
+
+ + + + +
+ )} + + {/* Username Step */} + {registrationStep === 'username' && ( +
+
👤
+

Choose Your Username

+

This is your unique identity on the platform

+ +
{ e.preventDefault(); setRegistrationStep('email'); }}> +
+ + setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))} + placeholder="e.g., alex_smith" + style={styles.input} + required + minLength={3} + maxLength={20} + autoFocus + /> +

3-20 characters, lowercase letters, numbers, _ and -

+
+ + {error &&
{error}
} + +
+ + +
+
+
+ )} + + {/* Email Step */} + {registrationStep === 'email' && ( +
+
📧
+

Backup Your Account

+

Add an email to access your account on other devices

+ +
+ 💡 +

+ We'll send you a secure link to set up your account on another device + (like your phone). This ensures you can always access your data, + even if you lose access to this browser. +

+
+ +
+ + setEmail(e.target.value)} + placeholder="your@email.com" + style={styles.input} + /> +
+ + {error &&
{error}
} + +
+ + +
+ + {!email && ( - ))} + )}
-
- )} - -
-

- {isRegistering - ? 'Create a new CryptID account using WebCryptoAPI for secure authentication.' - : existingUsers.length > 0 - ? 'Select an account above or enter a different username to sign in.' - : 'Sign in using your CryptID credentials.' - } -

-
- ✓ ECDSA P-256 Key Pairs - ✓ Challenge-Response Authentication - ✓ Secure Key Storage -
+ )} + + {/* Success Step */} + {registrationStep === 'success' && ( +
+
+

Welcome, {username}!

+

Your CryptID account is ready

+ +
+
+ + Cryptographic keys generated +
+
+ + Keys stored securely in this browser +
+ {emailSent && ( +
+ + Backup email sent to {email} +
+ )} +
+ + {emailSent && ( +
+ 📱 +

+ Next step: Check your email and open the backup link + on your phone or another device to complete multi-device setup. +

+
+ )} + + +
+ )} + + {onCancel && registrationStep !== 'success' && ( + + )}
+ ); + } -
-
- - setUsername(e.target.value)} - placeholder={existingUsers.length > 0 ? "Enter username or select from above" : "Enter username"} - required - disabled={isLoading} - autoComplete="username" - minLength={3} - maxLength={20} - /> -
+ // Login flow + return ( +
+
+
🔐
+

Sign In with CryptID

- {error &&
{error}
} + {existingUsers.length > 0 && ( +
+

Your accounts on this device:

+
+ {existingUsers.map((user) => ( + + ))} +
+
+ )} - - +
+
+ + setUsername(e.target.value)} + placeholder="Enter your username" + style={styles.input} + required + disabled={isLoading} + /> +
-
-
} + + +
+ + -
- {onCancel && ( - - )} + {onCancel && ( + + )} +
); }; -export default CryptID; \ No newline at end of file +// Styles +const styles: Record = { + container: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: '20px', + maxWidth: '440px', + margin: '0 auto', + }, + card: { + width: '100%', + backgroundColor: 'var(--color-panel, #fff)', + borderRadius: '16px', + padding: '32px', + boxShadow: '0 4px 24px rgba(0, 0, 0, 0.1)', + textAlign: 'center', + }, + errorCard: { + width: '100%', + backgroundColor: '#fef2f2', + borderRadius: '16px', + padding: '32px', + textAlign: 'center', + }, + stepIndicator: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginBottom: '24px', + gap: '0', + }, + stepDot: { + width: '28px', + height: '28px', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '12px', + fontWeight: 600, + color: 'white', + }, + stepLine: { + width: '40px', + height: '2px', + backgroundColor: '#e5e7eb', + }, + iconLarge: { + fontSize: '48px', + marginBottom: '16px', + }, + errorIcon: { + fontSize: '48px', + marginBottom: '16px', + }, + successIcon: { + width: '64px', + height: '64px', + borderRadius: '50%', + backgroundColor: '#22c55e', + color: 'white', + fontSize: '32px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + margin: '0 auto 16px', + }, + title: { + fontSize: '24px', + fontWeight: 700, + color: 'var(--color-text, #1f2937)', + marginBottom: '8px', + margin: '0 0 8px 0', + }, + subtitle: { + fontSize: '14px', + color: 'var(--color-text-3, #6b7280)', + marginBottom: '24px', + margin: '0 0 24px 0', + }, + description: { + fontSize: '14px', + color: '#6b7280', + lineHeight: 1.6, + marginBottom: '24px', + }, + explainerBox: { + backgroundColor: 'var(--color-muted-2, #f9fafb)', + borderRadius: '12px', + padding: '20px', + marginBottom: '24px', + textAlign: 'left', + }, + explainerTitle: { + fontSize: '14px', + fontWeight: 600, + color: 'var(--color-text, #1f2937)', + marginBottom: '16px', + margin: '0 0 16px 0', + }, + explainerContent: { + display: 'flex', + flexDirection: 'column', + gap: '16px', + }, + explainerItem: { + display: 'flex', + gap: '12px', + alignItems: 'flex-start', + }, + explainerIcon: { + fontSize: '20px', + flexShrink: 0, + }, + explainerText: { + fontSize: '12px', + color: 'var(--color-text-3, #6b7280)', + margin: '4px 0 0 0', + lineHeight: 1.5, + }, + featureList: { + display: 'flex', + flexDirection: 'column', + gap: '8px', + marginBottom: '24px', + }, + featureItem: { + display: 'flex', + alignItems: 'center', + gap: '8px', + fontSize: '13px', + color: 'var(--color-text, #374151)', + }, + infoBox: { + display: 'flex', + gap: '12px', + padding: '16px', + backgroundColor: 'rgba(139, 92, 246, 0.1)', + borderRadius: '10px', + marginBottom: '20px', + textAlign: 'left', + }, + infoIcon: { + fontSize: '20px', + flexShrink: 0, + }, + infoText: { + fontSize: '13px', + color: 'var(--color-text, #374151)', + margin: 0, + lineHeight: 1.5, + }, + successBox: { + display: 'flex', + flexDirection: 'column', + gap: '12px', + padding: '16px', + backgroundColor: 'rgba(34, 197, 94, 0.1)', + borderRadius: '10px', + marginBottom: '20px', + }, + successItem: { + display: 'flex', + alignItems: 'center', + gap: '10px', + fontSize: '14px', + color: 'var(--color-text, #374151)', + }, + successCheck: { + color: '#22c55e', + fontWeight: 600, + }, + inputGroup: { + marginBottom: '20px', + textAlign: 'left', + }, + label: { + display: 'block', + fontSize: '13px', + fontWeight: 500, + color: 'var(--color-text, #374151)', + marginBottom: '6px', + }, + input: { + width: '100%', + padding: '12px 14px', + fontSize: '15px', + border: '2px solid var(--color-panel-contrast, #e5e7eb)', + borderRadius: '10px', + backgroundColor: 'var(--color-panel, #fff)', + color: 'var(--color-text, #1f2937)', + outline: 'none', + transition: 'border-color 0.15s', + boxSizing: 'border-box', + }, + hint: { + fontSize: '11px', + color: 'var(--color-text-3, #9ca3af)', + marginTop: '6px', + margin: '6px 0 0 0', + }, + error: { + padding: '12px', + backgroundColor: '#fef2f2', + color: '#dc2626', + borderRadius: '8px', + fontSize: '13px', + marginBottom: '16px', + }, + buttonGroup: { + display: 'flex', + gap: '12px', + }, + primaryButton: { + flex: 1, + padding: '14px 24px', + fontSize: '15px', + fontWeight: 600, + color: 'white', + background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)', + border: 'none', + borderRadius: '10px', + cursor: 'pointer', + transition: 'transform 0.15s, box-shadow 0.15s', + boxShadow: '0 4px 12px rgba(139, 92, 246, 0.3)', + }, + secondaryButton: { + flex: 1, + padding: '14px 24px', + fontSize: '15px', + fontWeight: 500, + color: 'var(--color-text, #374151)', + backgroundColor: 'var(--color-muted-2, #f3f4f6)', + border: 'none', + borderRadius: '10px', + cursor: 'pointer', + }, + linkButton: { + marginTop: '16px', + padding: '8px', + fontSize: '13px', + color: '#8b5cf6', + backgroundColor: 'transparent', + border: 'none', + cursor: 'pointer', + textDecoration: 'underline', + }, + cancelButton: { + marginTop: '16px', + padding: '8px 16px', + fontSize: '13px', + color: 'var(--color-text-3, #6b7280)', + backgroundColor: 'transparent', + border: 'none', + cursor: 'pointer', + }, + existingUsers: { + marginBottom: '20px', + textAlign: 'left', + }, + existingUsersLabel: { + fontSize: '13px', + color: 'var(--color-text-3, #6b7280)', + marginBottom: '10px', + margin: '0 0 10px 0', + }, + userList: { + display: 'flex', + flexDirection: 'column', + gap: '8px', + }, + userButton: { + display: 'flex', + alignItems: 'center', + gap: '10px', + padding: '12px 14px', + border: '2px solid #e5e7eb', + borderRadius: '10px', + backgroundColor: 'transparent', + cursor: 'pointer', + transition: 'all 0.15s', + width: '100%', + textAlign: 'left', + }, + userIcon: { + fontSize: '18px', + }, + userName: { + flex: 1, + fontSize: '14px', + fontWeight: 500, + color: 'var(--color-text, #374151)', + }, + selectedBadge: { + fontSize: '11px', + padding: '2px 8px', + backgroundColor: '#8b5cf6', + color: 'white', + borderRadius: '10px', + fontWeight: 500, + }, +}; + +export default CryptID; diff --git a/src/components/auth/CryptIDDropdown.tsx b/src/components/auth/CryptIDDropdown.tsx index 58390ac..2233b8e 100644 --- a/src/components/auth/CryptIDDropdown.tsx +++ b/src/components/auth/CryptIDDropdown.tsx @@ -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 = ({ 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>({ @@ -27,6 +33,58 @@ const CryptIDDropdown: React.FC = ({ isDarkMode = false }) }); const dropdownRef = useRef(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([]); + const [connectionsLoading, setConnectionsLoading] = useState(false); + const [editingConnectionId, setEditingConnectionId] = useState(null); + const [editingMetadata, setEditingMetadata] = useState>({}); + const [savingMetadata, setSavingMetadata] = useState(false); + const [connectingUserId, setConnectingUserId] = useState(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 = ({ 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 = ({ 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 = ({ isDarkMode = false }) } return ( -
+
{/* Trigger button */}
+ {/* Quick actions */} + + {/* Integrations section */}
= ({ 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
+ + {/* Obsidian Vault */} +
+
+
+ 📁 +
+
+
+ Obsidian Vault +
+
+ {session.obsidianVaultName || 'Not connected'} +
+
+ {session.obsidianVaultName && ( + + )} +
+ +
+ + {/* Fathom Meetings */} +
+
+
+ 🎥 +
+
+
+ Fathom Meetings +
+
+ {hasFathomApiKey ? 'Connected' : 'Not connected'} +
+
+ {hasFathomApiKey && ( + + )} +
+ {showFathomInput ? ( +
+ 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 + /> +
+ + +
+
+ ) : ( +
+ + {hasFathomApiKey && ( + + )} +
+ )} +
{/* Sign out */} @@ -410,6 +878,240 @@ const CryptIDDropdown: React.FC = ({ isDarkMode = false }) isDarkMode={isDarkMode} /> )} + + {/* Obsidian Vault Connection Modal */} + {showObsidianModal && ( +
{ + if (e.target === e.currentTarget) { + setShowObsidianModal(false); + } + }} + > +
e.stopPropagation()} + > +
+
+ 📁 +
+
+

+ Connect Obsidian Vault +

+

+ Import your notes to the canvas +

+
+
+ + {session.obsidianVaultName && ( +
+ +
+
+ Currently connected +
+
+ {session.obsidianVaultName} +
+
+
+ )} + +
+ {/* Option 1: Select Local Folder */} + + + {/* Option 2: Enter URL */} +
+ 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 && ( + + )} +
+ + {session.obsidianVaultName && ( + + )} +
+ + +
+
+ )}
); }; diff --git a/src/components/history/VersionHistoryPanel.tsx b/src/components/history/VersionHistoryPanel.tsx new file mode 100644 index 0000000..5c0756f --- /dev/null +++ b/src/components/history/VersionHistoryPanel.tsx @@ -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; + removed: Record; + modified: Record; +} + +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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedEntry, setSelectedEntry] = useState(null); + const [diff, setDiff] = useState(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 ( +
+ {/* Header */} +
+
+ + + + + + Version History + +
+ +
+ + {/* Content */} +
+ {isLoading ? ( +
+ Loading history... +
+ ) : error ? ( +
+ {error} + +
+ ) : history.length === 0 ? ( +
+ No version history available +
+ ) : ( + <> + {/* Timeline */} +
+ {history.map((entry, index) => ( +
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'; + } + }} + > +
+ {entry.message || `Change ${entry.hash.slice(0, 8)}`} +
+
+ {formatTimestamp(entry.timestamp)} +
+
+ ))} +
+ + {/* Diff View */} + {selectedEntry && ( +
+
+ Changes in this version +
+ + {isLoadingDiff ? ( +
+ Loading diff... +
+ ) : diff ? ( +
+ {/* Added */} + {Object.entries(diff.added).length > 0 && ( +
+
+ + Added ({Object.entries(diff.added).filter(([id]) => id.startsWith('shape:')).length} shapes) +
+ {Object.entries(diff.added) + .filter(([id]) => id.startsWith('shape:')) + .slice(0, 10) + .map(([id, record]) => ( +
+ {getShapeLabel(record)} +
+ ))} + {Object.entries(diff.added).filter(([id]) => id.startsWith('shape:')).length > 10 && ( +
+ ...and {Object.entries(diff.added).filter(([id]) => id.startsWith('shape:')).length - 10} more +
+ )} +
+ )} + + {/* Removed */} + {Object.entries(diff.removed).length > 0 && ( +
+
+ - Removed ({Object.entries(diff.removed).filter(([id]) => id.startsWith('shape:')).length} shapes) +
+ {Object.entries(diff.removed) + .filter(([id]) => id.startsWith('shape:')) + .slice(0, 10) + .map(([id, record]) => ( +
+ {getShapeLabel(record)} +
+ ))} + {Object.entries(diff.removed).filter(([id]) => id.startsWith('shape:')).length > 10 && ( +
+ ...and {Object.entries(diff.removed).filter(([id]) => id.startsWith('shape:')).length - 10} more +
+ )} +
+ )} + + {/* Modified */} + {Object.entries(diff.modified).length > 0 && ( +
+
+ ~ Modified ({Object.entries(diff.modified).filter(([id]) => id.startsWith('shape:')).length} shapes) +
+ {Object.entries(diff.modified) + .filter(([id]) => id.startsWith('shape:')) + .slice(0, 5) + .map(([id, { after }]) => ( +
+ {getShapeLabel(after)} +
+ ))} + {Object.entries(diff.modified).filter(([id]) => id.startsWith('shape:')).length > 5 && ( +
+ ...and {Object.entries(diff.modified).filter(([id]) => id.startsWith('shape:')).length - 5} more +
+ )} +
+ )} + + {/* 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 && ( +
+ No visible shape changes in this version +
+ )} +
+ ) : ( +
+ Select a version to see changes +
+ )} + + {/* Revert Button */} + {selectedEntry && history.indexOf(selectedEntry) !== 0 && ( +
+ {showConfirmRevert ? ( +
+
+ Are you sure you want to revert to this version? This will restore the board to this point in time. +
+
+ + +
+
+ ) : ( + + )} +
+ )} +
+ )} + + )} +
+
+ ); +} + +export default VersionHistoryPanel; diff --git a/src/components/history/index.ts b/src/components/history/index.ts new file mode 100644 index 0000000..cca2137 --- /dev/null +++ b/src/components/history/index.ts @@ -0,0 +1,3 @@ +export { VersionHistoryPanel } from './VersionHistoryPanel'; +export { useVersionHistory } from './useVersionHistory'; +export type { HistoryEntry, SnapshotDiff, UseVersionHistoryReturn } from './useVersionHistory'; diff --git a/src/components/history/useVersionHistory.ts b/src/components/history/useVersionHistory.ts new file mode 100644 index 0000000..efa4569 --- /dev/null +++ b/src/components/history/useVersionHistory.ts @@ -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; + removed: Record; + modified: Record; +} + +export interface UseVersionHistoryReturn { + history: HistoryEntry[]; + isLoading: boolean; + error: string | null; + fetchHistory: () => Promise; + fetchDiff: (fromHash: string | null, toHash: string | null) => Promise; + revert: (hash: string) => Promise; +} + +export function useVersionHistory(roomId: string): UseVersionHistoryReturn { + const [history, setHistory] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + 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 => { + 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, + }; +} diff --git a/src/components/networking/NetworkGraphMinimap.tsx b/src/components/networking/NetworkGraphMinimap.tsx index 7988697..11bad68 100644 --- a/src/components/networking/NetworkGraphMinimap.tsx +++ b/src/components/networking/NetworkGraphMinimap.tsx @@ -29,7 +29,7 @@ interface NetworkGraphMinimapProps { edges: GraphEdge[]; myConnections: string[]; currentUserId?: string; - onConnect: (userId: string) => Promise; + onConnect: (userId: string, trustLevel?: TrustLevel) => Promise; onDisconnect?: (connectionId: string) => Promise; 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 { } // ============================================================================= -// 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(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 | 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(simNodes) .force('link', d3.forceLink(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); } diff --git a/src/components/networking/NetworkGraphPanel.tsx b/src/components/networking/NetworkGraphPanel.tsx index fc383fd..7957818 100644 --- a/src/components/networking/NetworkGraphPanel.tsx +++ b/src/components/networking/NetworkGraphPanel.tsx @@ -30,6 +30,24 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) { const [isCollapsed, setIsCollapsed] = useState(false); const [selectedEdge, setSelectedEdge] = useState(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} /> ); } diff --git a/src/components/networking/useNetworkGraph.ts b/src/components/networking/useNetworkGraph.ts index 8e451d4..6831052 100644 --- a/src/components/networking/useNetworkGraph.ts +++ b/src/components/networking/useNetworkGraph.ts @@ -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; - // Connect to a user - connect: (userId: string) => Promise; + // Connect to a user with optional trust level + connect: (userId: string, trustLevel?: TrustLevel) => Promise; // Disconnect from a user disconnect: (connectionId: string) => Promise; // 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(); diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 1f9151c..92f0d93 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -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(undefined) export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [session, setSessionState] = useState(initialSession); + const [accessToken, setAccessTokenState] = useState(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) => { @@ -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 => { - // 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 ( diff --git a/src/hooks/useMaximize.ts b/src/hooks/useMaximize.ts index 73d4fdb..cd2815f 100644 --- a/src/hooks/useMaximize.ts +++ b/src/hooks/useMaximize.ts @@ -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 */ diff --git a/src/lib/auth/types.ts b/src/lib/auth/types.ts index 0b7e051..61e7b47 100644 --- a/src/lib/auth/types.ts +++ b/src/lib/auth/types.ts @@ -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; diff --git a/src/lib/networking/connectionService.ts b/src/lib/networking/connectionService.ts index 9f763a9..5ca55fb 100644 --- a/src/lib/networking/connectionService.ts +++ b/src/lib/networking/connectionService.ts @@ -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(url: string, options?: RequestInit): Promise { + // Get the current user ID for authentication + const userId = getCurrentUserId(); + + const headers: Record = { + 'Content-Type': 'application/json', + ...(options?.headers as Record || {}), + }; + + // 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) { diff --git a/src/shapes/ImageGenShapeUtil.tsx b/src/shapes/ImageGenShapeUtil.tsx index 01bf2b1..11c288d 100644 --- a/src/shapes/ImageGenShapeUtil.tsx +++ b/src/shapes/ImageGenShapeUtil.tsx @@ -948,28 +948,33 @@ export class ImageGenShape extends BaseBoxShapeUtil { )}
- {/* Input Section */} + {/* Input Section - Mobile Optimized */}
- { editor.updateShape({ @@ -1000,20 +1005,26 @@ export class ImageGenShape extends BaseBoxShapeUtil { />
diff --git a/src/shapes/MapShapeUtil.tsx b/src/shapes/MapShapeUtil.tsx index ce91993..0912bdf 100644 --- a/src/shapes/MapShapeUtil.tsx +++ b/src/shapes/MapShapeUtil.tsx @@ -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)} m²`; + } 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([]); + const drawingPointsRef = useRef([]); + + // 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([]); @@ -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, }} /> -
- {ann.name} +
+
+ {ann.name} +
+ {ann.type === 'line' && ann.coordinates.length >= 2 && ( +
+ 📏 {formatDistance(calculateDistance(ann.coordinates))} +
+ )} + {ann.type === 'area' && ann.coordinates.length >= 3 && ( +
+ ⬡ {formatArea(calculateArea(ann.coordinates))} • {formatDistance(calculateDistance([...ann.coordinates, ann.coordinates[0]]))} perimeter +
+ )}
+ {/* Measurement Display and Drawing Instructions */} + {(activeTool === 'line' || activeTool === 'area') && ( +
+ {drawingPoints.length > 0 && ( +
+ {activeTool === 'line' && formatDistance(calculateDistance(drawingPoints))} + {activeTool === 'area' && drawingPoints.length >= 3 && formatArea(calculateArea(drawingPoints))} + {activeTool === 'area' && drawingPoints.length < 3 && `${drawingPoints.length}/3 points`} +
+ )} +
+ {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'} +
+
+ )} + {/* Drawing Toolbar (Mapus-style) */}
{/* Cursor Tool */} diff --git a/src/ui/CustomToolbar.tsx b/src/ui/CustomToolbar.tsx index f1cdc6e..2b12a2e 100644 --- a/src/ui/CustomToolbar.tsx +++ b/src/ui/CustomToolbar.tsx @@ -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 ( -
-
- - - - {session.authed && ( -
- - - {showProfilePopup && ( -
-
-
- - - -
-
- {session.username} - CryptID Account -
-
- -
- - - - - - My Saved Boards - - -
- - {/* General Settings */} - - -
- - {/* AI Models Section */} - - - {expandedSection === 'ai' && ( -
-

- Local models are free. Cloud models require API keys. -

- {AI_TOOLS.map((tool) => ( -
- - {tool.icon} - {tool.name} - - - {tool.model} - -
- ))} - -
- )} - - {/* Integrations Section */} - - - {expandedSection === 'integrations' && ( -
- {/* Obsidian Vault */} -
-
- - 📁 Obsidian Vault - - - {session.obsidianVaultName ? 'Connected' : 'Not Set'} - -
- {session.obsidianVaultName && ( -

{session.obsidianVaultName}

- )} - -
- - {/* Fathom Meetings */} -
-
- - 🎥 Fathom Meetings - - - {hasFathomApiKey ? 'Connected' : 'Not Set'} - -
- - {showFathomInput ? ( -
- 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 - /> -
- - -
- - Get API key from Fathom → - -
- ) : ( -
- - {hasFathomApiKey && ( - - )} -
- )} -
-
- )} - - {/* Connections Section */} - - - {expandedSection === 'connections' && ( -
- {/* People in Canvas Section */} - {canvasUsers.length > 0 && ( -
-
- People in Canvas - - {canvasUsers.length} - -
-
- {canvasUsers.map((user) => ( -
-
-
- {/* User avatar with presence color */} -
- {user.name.charAt(0).toUpperCase()} -
-
-
- {user.name} -
-
- {user.connectionStatus === 'trusted' ? 'Trusted' : - user.connectionStatus === 'connected' ? 'Connected' : - 'Not connected'} -
-
-
- - {/* Connection status indicator & actions */} -
- {connectingUserId === user.id ? ( - ... - ) : user.connectionStatus === 'unconnected' ? ( - <> - - - - ) : ( - <> - {/* Toggle between connected and trusted */} - {user.connectionStatus === 'connected' ? ( - - ) : ( - - )} - - - )} -
-
-
- ))} -
-
- )} - - {/* Divider if both sections exist */} - {canvasUsers.length > 0 && connections.length > 0 && ( -
- )} - - {/* My Connections Section */} -
- My Connections -
- - {connectionsLoading ? ( -

- Loading connections... -

- ) : connections.length === 0 ? ( -
-

- No connections yet -

-

- Connect with people in the canvas above -

-
- ) : ( -
- {connections.map((conn) => ( -
- {/* Connection Header */} -
-
-
- {(conn.toProfile?.displayName || conn.toUserId).charAt(0).toUpperCase()} -
-
-
- {conn.toProfile?.displayName || conn.toUserId} -
-
- @{conn.toUserId} -
-
-
- - {conn.trustLevel === 'trusted' ? 'Trusted' : 'Connected'} - -
- - {/* Mutual Connection Badge */} - {conn.isMutual && ( -
- ✓ Mutual connection -
- )} - - {/* Edge Metadata Display/Edit */} - {editingConnectionId === conn.id ? ( -
-
- - 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' }} - /> -
-
- -