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:
parent
2e9c5d583c
commit
9273d741b9
|
|
@ -81,7 +81,6 @@
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
<<<<<<< HEAD
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"multmux/packages/cli": {
|
"multmux/packages/cli": {
|
||||||
|
|
@ -199,8 +198,6 @@
|
||||||
"utf-8-validate": {
|
"utf-8-validate": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
=======
|
|
||||||
>>>>>>> db7bbbf (feat: add invite/share feature with QR code, URL, NFC, and audio connect)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ai-sdk/provider": {
|
"node_modules/@ai-sdk/provider": {
|
||||||
|
|
@ -14876,6 +14873,15 @@
|
||||||
"node": ">=16.0.0"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.13.0",
|
"version": "6.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||||
|
|
@ -14891,14 +14897,6 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/querystringify": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
|
|
@ -62,7 +109,13 @@ const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) =
|
||||||
e.currentTarget.style.background = "#3b82f6";
|
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>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,75 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't show the button if user is not authenticated
|
// Detect if we're in share-panel (compact) vs toolbar (full button)
|
||||||
if (!session.authed) {
|
const isCompact = className.includes('share-panel-btn');
|
||||||
return null;
|
|
||||||
|
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 (
|
return (
|
||||||
|
|
@ -86,14 +152,14 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
|
||||||
onClick={handleStarToggle}
|
onClick={handleStarToggle}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={`toolbar-btn star-board-button ${className} ${isStarred ? 'starred' : ''}`}
|
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 ? (
|
{isLoading ? (
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="loading-spinner">
|
<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"/>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<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 ? (
|
{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="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"/>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -3,29 +3,36 @@ import { CryptoAuthService } from '../../lib/auth/cryptoAuthService';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { useNotifications } from '../../context/NotificationContext';
|
import { useNotifications } from '../../context/NotificationContext';
|
||||||
import { checkBrowserSupport, isSecureContext } from '../../lib/utils/browser';
|
import { checkBrowserSupport, isSecureContext } from '../../lib/utils/browser';
|
||||||
|
import { WORKER_URL } from '../../constants/workerUrl';
|
||||||
|
|
||||||
interface CryptIDProps {
|
interface CryptIDProps {
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RegistrationStep = 'welcome' | 'username' | 'email' | 'success';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CryptID - WebCryptoAPI-based authentication component
|
* CryptID - WebCryptoAPI-based authentication component
|
||||||
|
* Enhanced with multi-step registration and email backup
|
||||||
*/
|
*/
|
||||||
const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
const [isRegistering, setIsRegistering] = useState(false);
|
const [isRegistering, setIsRegistering] = useState(false);
|
||||||
|
const [registrationStep, setRegistrationStep] = useState<RegistrationStep>('welcome');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [existingUsers, setExistingUsers] = useState<string[]>([]);
|
const [existingUsers, setExistingUsers] = useState<string[]>([]);
|
||||||
const [suggestedUsername, setSuggestedUsername] = useState<string>('');
|
const [suggestedUsername, setSuggestedUsername] = useState<string>('');
|
||||||
|
const [emailSent, setEmailSent] = useState(false);
|
||||||
const [browserSupport, setBrowserSupport] = useState<{
|
const [browserSupport, setBrowserSupport] = useState<{
|
||||||
supported: boolean;
|
supported: boolean;
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
webcrypto: boolean;
|
webcrypto: boolean;
|
||||||
}>({ supported: false, secure: false, webcrypto: false });
|
}>({ supported: false, secure: false, webcrypto: false });
|
||||||
|
|
||||||
const { setSession } = useAuth();
|
const { setSession, updateSession } = useAuth();
|
||||||
const { addNotification } = useNotifications();
|
const { addNotification } = useNotifications();
|
||||||
|
|
||||||
// Check browser support and existing users on mount
|
// Check browser support and existing users on mount
|
||||||
|
|
@ -53,43 +60,32 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
|
|
||||||
const checkExistingUsers = () => {
|
const checkExistingUsers = () => {
|
||||||
try {
|
try {
|
||||||
// Get registered users from localStorage
|
|
||||||
const users = JSON.parse(localStorage.getItem('registeredUsers') || '[]');
|
const users = JSON.parse(localStorage.getItem('registeredUsers') || '[]');
|
||||||
|
|
||||||
// Filter users to only include those with valid authentication keys
|
|
||||||
const validUsers = users.filter((user: string) => {
|
const validUsers = users.filter((user: string) => {
|
||||||
// Check if public key exists
|
|
||||||
const publicKey = localStorage.getItem(`${user}_publicKey`);
|
const publicKey = localStorage.getItem(`${user}_publicKey`);
|
||||||
if (!publicKey) return false;
|
if (!publicKey) return false;
|
||||||
|
|
||||||
// Check if authentication data exists
|
|
||||||
const authData = localStorage.getItem(`${user}_authData`);
|
const authData = localStorage.getItem(`${user}_authData`);
|
||||||
if (!authData) return false;
|
if (!authData) return false;
|
||||||
|
|
||||||
// Verify the auth data is valid JSON and has required fields
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(authData);
|
const parsed = JSON.parse(authData);
|
||||||
return parsed.challenge && parsed.signature && parsed.timestamp;
|
return parsed.challenge && parsed.signature && parsed.timestamp;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Invalid auth data for user ${user}:`, e);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setExistingUsers(validUsers);
|
setExistingUsers(validUsers);
|
||||||
|
|
||||||
// If there are valid users, suggest the first one for login
|
|
||||||
if (validUsers.length > 0) {
|
if (validUsers.length > 0) {
|
||||||
setSuggestedUsername(validUsers[0]);
|
setSuggestedUsername(validUsers[0]);
|
||||||
setUsername(validUsers[0]); // Pre-fill the username field
|
setUsername(validUsers[0]);
|
||||||
setIsRegistering(false); // Default to login mode if users exist
|
setIsRegistering(false);
|
||||||
} else {
|
} else {
|
||||||
setIsRegistering(true); // Default to registration mode if no users exist
|
setIsRegistering(true);
|
||||||
}
|
setRegistrationStep('welcome');
|
||||||
|
|
||||||
// Log for debugging
|
|
||||||
if (users.length !== validUsers.length) {
|
|
||||||
console.log(`Found ${users.length} registered users, but only ${validUsers.length} have valid keys`);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking existing users:', error);
|
console.error('Error checking existing users:', error);
|
||||||
|
|
@ -102,178 +98,759 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
}, [addNotification]);
|
}, [addNotification]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle form submission for both login and registration
|
* Send backup email with magic link
|
||||||
*/
|
*/
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const sendBackupEmail = async (userEmail: string, userName: string) => {
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!browserSupport.supported || !browserSupport.secure || !browserSupport.webcrypto) {
|
// Call the Worker API to send backup email
|
||||||
setError('Browser does not support cryptographic authentication');
|
const response = await fetch(`${WORKER_URL}/api/auth/send-backup-email`, {
|
||||||
setIsLoading(false);
|
method: 'POST',
|
||||||
return;
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
body: JSON.stringify({ email: userEmail, username: userName }),
|
||||||
|
});
|
||||||
|
|
||||||
if (isRegistering) {
|
if (response.ok) {
|
||||||
// Registration flow using CryptoAuthService
|
setEmailSent(true);
|
||||||
const result = await CryptoAuthService.register(username);
|
// Update session with email
|
||||||
if (result.success && result.session) {
|
updateSession({ email: userEmail, backupCreated: true });
|
||||||
setSession(result.session);
|
addNotification('Backup email sent! Check your inbox.', 'success');
|
||||||
if (onSuccess) onSuccess();
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Registration failed');
|
|
||||||
addNotification('Registration failed. Please try again.', 'error');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Login flow using CryptoAuthService
|
const data = await response.json() as { error?: string };
|
||||||
const result = await CryptoAuthService.login(username);
|
throw new Error(data.error || 'Failed to send email');
|
||||||
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) {
|
} catch (err) {
|
||||||
console.error('Cryptographic authentication error:', err);
|
console.error('Failed to send backup email:', err);
|
||||||
setError('An unexpected error occurred during authentication');
|
// Don't block registration if email fails
|
||||||
addNotification('Authentication error. Please try again later.', 'error');
|
addNotification('Could not send backup email, but your account is created.', 'warning');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
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 (
|
return (
|
||||||
<div className="crypto-login-container">
|
<div style={styles.container}>
|
||||||
<h2>Browser Not Supported</h2>
|
<div style={styles.errorCard}>
|
||||||
<p>Your browser does not support the required features for cryptographic authentication.</p>
|
<div style={styles.errorIcon}>⚠️</div>
|
||||||
<p>Please use a modern browser with WebCryptoAPI support.</p>
|
<h2 style={styles.title}>
|
||||||
{onCancel && (
|
{!browserSupport.supported ? 'Browser Not Supported' : 'Secure Connection Required'}
|
||||||
<button onClick={onCancel} className="cancel-button">
|
</h2>
|
||||||
Go Back
|
<p style={styles.description}>
|
||||||
</button>
|
{!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.'}
|
||||||
|
</p>
|
||||||
|
{onCancel && (
|
||||||
|
<button onClick={onCancel} style={styles.secondaryButton}>
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!browserSupport.secure) {
|
// Registration flow
|
||||||
|
if (isRegistering) {
|
||||||
return (
|
return (
|
||||||
<div className="crypto-login-container">
|
<div style={styles.container}>
|
||||||
<h2>Secure Context Required</h2>
|
{/* Step indicator */}
|
||||||
<p>Cryptographic authentication requires a secure context (HTTPS).</p>
|
<div style={styles.stepIndicator}>
|
||||||
<p>Please access this application over HTTPS.</p>
|
{['welcome', 'username', 'email', 'success'].map((step, index) => (
|
||||||
{onCancel && (
|
<React.Fragment key={step}>
|
||||||
<button onClick={onCancel} className="cancel-button">
|
<div style={{
|
||||||
Go Back
|
...styles.stepDot,
|
||||||
</button>
|
backgroundColor:
|
||||||
|
registrationStep === step ? '#8b5cf6' :
|
||||||
|
['welcome', 'username', 'email', 'success'].indexOf(registrationStep) > index ? '#22c55e' : '#e5e7eb'
|
||||||
|
}}>
|
||||||
|
{['welcome', 'username', 'email', 'success'].indexOf(registrationStep) > index ? '✓' : index + 1}
|
||||||
|
</div>
|
||||||
|
{index < 3 && <div style={styles.stepLine} />}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Welcome Step */}
|
||||||
|
{registrationStep === 'welcome' && (
|
||||||
|
<div style={styles.card}>
|
||||||
|
<div style={styles.iconLarge}>🔐</div>
|
||||||
|
<h2 style={styles.title}>Welcome to CryptID</h2>
|
||||||
|
<p style={styles.subtitle}>Passwordless, secure authentication</p>
|
||||||
|
|
||||||
|
<div style={styles.explainerBox}>
|
||||||
|
<h3 style={styles.explainerTitle}>How does passwordless login work?</h3>
|
||||||
|
<div style={styles.explainerContent}>
|
||||||
|
<div style={styles.explainerItem}>
|
||||||
|
<span style={styles.explainerIcon}>🔑</span>
|
||||||
|
<div>
|
||||||
|
<strong>Cryptographic Keys</strong>
|
||||||
|
<p style={styles.explainerText}>
|
||||||
|
When you create an account, your browser generates a unique cryptographic key pair.
|
||||||
|
The private key never leaves your device.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={styles.explainerItem}>
|
||||||
|
<span style={styles.explainerIcon}>💾</span>
|
||||||
|
<div>
|
||||||
|
<strong>Secure Storage</strong>
|
||||||
|
<p style={styles.explainerText}>
|
||||||
|
Your keys are stored securely in your browser using WebCryptoAPI -
|
||||||
|
the same technology used by banks and governments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={styles.explainerItem}>
|
||||||
|
<span style={styles.explainerIcon}>📱</span>
|
||||||
|
<div>
|
||||||
|
<strong>Multi-Device Access</strong>
|
||||||
|
<p style={styles.explainerText}>
|
||||||
|
Add your email to receive a backup link. Open it on another device
|
||||||
|
(like your phone) to sync your account securely.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.featureList}>
|
||||||
|
<div style={styles.featureItem}>
|
||||||
|
<span style={{ color: '#22c55e' }}>✓</span> No password to remember or lose
|
||||||
|
</div>
|
||||||
|
<div style={styles.featureItem}>
|
||||||
|
<span style={{ color: '#22c55e' }}>✓</span> Phishing-resistant authentication
|
||||||
|
</div>
|
||||||
|
<div style={styles.featureItem}>
|
||||||
|
<span style={{ color: '#22c55e' }}>✓</span> Your data stays encrypted
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setRegistrationStep('username')}
|
||||||
|
style={styles.primaryButton}
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsRegistering(false);
|
||||||
|
if (existingUsers.length > 0) {
|
||||||
|
setUsername(existingUsers[0]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={styles.linkButton}
|
||||||
|
>
|
||||||
|
Already have an account? Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
{/* Username Step */}
|
||||||
<div className="crypto-login-container">
|
{registrationStep === 'username' && (
|
||||||
<h2>{isRegistering ? 'Create CryptID Account' : 'CryptID Sign In'}</h2>
|
<div style={styles.card}>
|
||||||
|
<div style={styles.iconLarge}>👤</div>
|
||||||
|
<h2 style={styles.title}>Choose Your Username</h2>
|
||||||
|
<p style={styles.subtitle}>This is your unique identity on the platform</p>
|
||||||
|
|
||||||
{/* Show existing users if available */}
|
<form onSubmit={(e) => { e.preventDefault(); setRegistrationStep('email'); }}>
|
||||||
{existingUsers.length > 0 && !isRegistering && (
|
<div style={styles.inputGroup}>
|
||||||
<div className="existing-users">
|
<label style={styles.label}>Username</label>
|
||||||
<h3>Available Accounts with Valid Keys</h3>
|
<input
|
||||||
<div className="user-list">
|
type="text"
|
||||||
{existingUsers.map((user) => (
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))}
|
||||||
|
placeholder="e.g., alex_smith"
|
||||||
|
style={styles.input}
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
maxLength={20}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<p style={styles.hint}>3-20 characters, lowercase letters, numbers, _ and -</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div style={styles.error}>{error}</div>}
|
||||||
|
|
||||||
|
<div style={styles.buttonGroup}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRegistrationStep('welcome')}
|
||||||
|
style={styles.secondaryButton}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={username.length < 3}
|
||||||
|
style={{
|
||||||
|
...styles.primaryButton,
|
||||||
|
opacity: username.length < 3 ? 0.5 : 1,
|
||||||
|
cursor: username.length < 3 ? 'not-allowed' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email Step */}
|
||||||
|
{registrationStep === 'email' && (
|
||||||
|
<div style={styles.card}>
|
||||||
|
<div style={styles.iconLarge}>📧</div>
|
||||||
|
<h2 style={styles.title}>Backup Your Account</h2>
|
||||||
|
<p style={styles.subtitle}>Add an email to access your account on other devices</p>
|
||||||
|
|
||||||
|
<div style={styles.infoBox}>
|
||||||
|
<span style={styles.infoIcon}>💡</span>
|
||||||
|
<p style={styles.infoText}>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.inputGroup}>
|
||||||
|
<label style={styles.label}>Email Address (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="your@email.com"
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div style={styles.error}>{error}</div>}
|
||||||
|
|
||||||
|
<div style={styles.buttonGroup}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRegistrationStep('username')}
|
||||||
|
style={styles.secondaryButton}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRegister}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={styles.primaryButton}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Creating Account...' : email ? 'Create & Send Backup' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!email && (
|
||||||
<button
|
<button
|
||||||
key={user}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUsername(user);
|
setEmail('');
|
||||||
setError(null);
|
handleRegister();
|
||||||
}}
|
}}
|
||||||
className={`user-option ${username === user ? 'selected' : ''}`}
|
style={styles.linkButton}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<span className="user-icon">🔐</span>
|
Skip for now
|
||||||
<span className="user-name">{user}</span>
|
|
||||||
<span className="user-status">Cryptographic keys available</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="crypto-info">
|
{/* Success Step */}
|
||||||
<p>
|
{registrationStep === 'success' && (
|
||||||
{isRegistering
|
<div style={styles.card}>
|
||||||
? 'Create a new CryptID account using WebCryptoAPI for secure authentication.'
|
<div style={styles.successIcon}>✓</div>
|
||||||
: existingUsers.length > 0
|
<h2 style={styles.title}>Welcome, {username}!</h2>
|
||||||
? 'Select an account above or enter a different username to sign in.'
|
<p style={styles.subtitle}>Your CryptID account is ready</p>
|
||||||
: 'Sign in using your CryptID credentials.'
|
|
||||||
}
|
<div style={styles.successBox}>
|
||||||
</p>
|
<div style={styles.successItem}>
|
||||||
<div className="crypto-features">
|
<span style={styles.successCheck}>✓</span>
|
||||||
<span className="feature">✓ ECDSA P-256 Key Pairs</span>
|
<span>Cryptographic keys generated</span>
|
||||||
<span className="feature">✓ Challenge-Response Authentication</span>
|
</div>
|
||||||
<span className="feature">✓ Secure Key Storage</span>
|
<div style={styles.successItem}>
|
||||||
</div>
|
<span style={styles.successCheck}>✓</span>
|
||||||
|
<span>Keys stored securely in this browser</span>
|
||||||
|
</div>
|
||||||
|
{emailSent && (
|
||||||
|
<div style={styles.successItem}>
|
||||||
|
<span style={styles.successCheck}>✓</span>
|
||||||
|
<span>Backup email sent to {email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{emailSent && (
|
||||||
|
<div style={styles.infoBox}>
|
||||||
|
<span style={styles.infoIcon}>📱</span>
|
||||||
|
<p style={styles.infoText}>
|
||||||
|
<strong>Next step:</strong> Check your email and open the backup link
|
||||||
|
on your phone or another device to complete multi-device setup.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onSuccess?.()}
|
||||||
|
style={styles.primaryButton}
|
||||||
|
>
|
||||||
|
Start Using Canvas
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onCancel && registrationStep !== 'success' && (
|
||||||
|
<button onClick={onCancel} style={styles.cancelButton}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
// Login flow
|
||||||
<div className="form-group">
|
return (
|
||||||
<label htmlFor="username">Username</label>
|
<div style={styles.container}>
|
||||||
<input
|
<div style={styles.card}>
|
||||||
type="text"
|
<div style={styles.iconLarge}>🔐</div>
|
||||||
id="username"
|
<h2 style={styles.title}>Sign In with CryptID</h2>
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
placeholder={existingUsers.length > 0 ? "Enter username or select from above" : "Enter username"}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
autoComplete="username"
|
|
||||||
minLength={3}
|
|
||||||
maxLength={20}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
{existingUsers.length > 0 && (
|
||||||
|
<div style={styles.existingUsers}>
|
||||||
|
<p style={styles.existingUsersLabel}>Your accounts on this device:</p>
|
||||||
|
<div style={styles.userList}>
|
||||||
|
{existingUsers.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user}
|
||||||
|
onClick={() => setUsername(user)}
|
||||||
|
style={{
|
||||||
|
...styles.userButton,
|
||||||
|
borderColor: username === user ? '#8b5cf6' : '#e5e7eb',
|
||||||
|
backgroundColor: username === user ? 'rgba(139, 92, 246, 0.1)' : 'transparent',
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<span style={styles.userIcon}>🔑</span>
|
||||||
|
<span style={styles.userName}>{user}</span>
|
||||||
|
{username === user && <span style={styles.selectedBadge}>Selected</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<form onSubmit={handleLogin}>
|
||||||
type="submit"
|
<div style={styles.inputGroup}>
|
||||||
disabled={isLoading || !username.trim()}
|
<label style={styles.label}>Username</label>
|
||||||
className="crypto-auth-button"
|
<input
|
||||||
>
|
type="text"
|
||||||
{isLoading ? 'Processing...' : isRegistering ? 'Create Account' : 'Sign In'}
|
value={username}
|
||||||
</button>
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
</form>
|
placeholder="Enter your username"
|
||||||
|
style={styles.input}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div style={styles.error}>{error}</div>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !username.trim()}
|
||||||
|
style={{
|
||||||
|
...styles.primaryButton,
|
||||||
|
opacity: (isLoading || !username.trim()) ? 0.5 : 1,
|
||||||
|
cursor: (isLoading || !username.trim()) ? 'not-allowed' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Signing In...' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div className="auth-toggle">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsRegistering(!isRegistering);
|
setIsRegistering(true);
|
||||||
|
setRegistrationStep('welcome');
|
||||||
|
setUsername('');
|
||||||
setError(null);
|
setError(null);
|
||||||
// Clear username when switching modes
|
|
||||||
if (!isRegistering) {
|
|
||||||
setUsername('');
|
|
||||||
} else if (existingUsers.length > 0) {
|
|
||||||
setUsername(existingUsers[0]);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
|
style={styles.linkButton}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="toggle-button"
|
|
||||||
>
|
>
|
||||||
{isRegistering ? 'Already have an account? Sign in' : 'Need an account? Register'}
|
Need an account? Create one
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<button onClick={onCancel} className="cancel-button">
|
<button onClick={onCancel} style={styles.cancelButton}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
|
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;
|
export default CryptID;
|
||||||
|
|
@ -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 { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useEditor, useValue } from 'tldraw';
|
||||||
import CryptID from './CryptID';
|
import CryptID from './CryptID';
|
||||||
import { GoogleDataService, type GoogleService } from '../../lib/google';
|
import { GoogleDataService, type GoogleService } from '../../lib/google';
|
||||||
import { GoogleExportBrowser } from '../GoogleExportBrowser';
|
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 {
|
interface CryptIDDropdownProps {
|
||||||
isDarkMode?: boolean;
|
isDarkMode?: boolean;
|
||||||
|
|
@ -13,10 +17,12 @@ interface CryptIDDropdownProps {
|
||||||
* Shows logged-in user with dropdown containing account info and integrations.
|
* Shows logged-in user with dropdown containing account info and integrations.
|
||||||
*/
|
*/
|
||||||
const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false }) => {
|
const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false }) => {
|
||||||
const { session, logout } = useAuth();
|
const { session, logout, updateSession } = useAuth();
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
const [showCryptIDModal, setShowCryptIDModal] = useState(false);
|
const [showCryptIDModal, setShowCryptIDModal] = useState(false);
|
||||||
const [showGoogleBrowser, setShowGoogleBrowser] = useState(false);
|
const [showGoogleBrowser, setShowGoogleBrowser] = useState(false);
|
||||||
|
const [showObsidianModal, setShowObsidianModal] = useState(false);
|
||||||
|
const [obsidianVaultUrl, setObsidianVaultUrl] = useState('');
|
||||||
const [googleConnected, setGoogleConnected] = useState(false);
|
const [googleConnected, setGoogleConnected] = useState(false);
|
||||||
const [googleLoading, setGoogleLoading] = useState(false);
|
const [googleLoading, setGoogleLoading] = useState(false);
|
||||||
const [googleCounts, setGoogleCounts] = useState<Record<GoogleService, number>>({
|
const [googleCounts, setGoogleCounts] = useState<Record<GoogleService, number>>({
|
||||||
|
|
@ -27,6 +33,58 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
});
|
});
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
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
|
// Check Google connection on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkGoogleStatus = async () => {
|
const checkGoogleStatus = async () => {
|
||||||
|
|
@ -45,17 +103,113 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
checkGoogleStatus();
|
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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
setShowDropdown(false);
|
setShowDropdown(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
if (showDropdown) {
|
if (showDropdown) {
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
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]);
|
}, [showDropdown]);
|
||||||
|
|
||||||
const handleGoogleConnect = async () => {
|
const handleGoogleConnect = async () => {
|
||||||
|
|
@ -66,6 +220,9 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
setGoogleConnected(true);
|
setGoogleConnected(true);
|
||||||
const counts = await service.getStoredCounts();
|
const counts = await service.getStoredCounts();
|
||||||
setGoogleCounts(counts);
|
setGoogleCounts(counts);
|
||||||
|
// After successful connection, open the Google Export Browser
|
||||||
|
setShowGoogleBrowser(true);
|
||||||
|
setShowDropdown(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Google auth failed:', error);
|
console.error('Google auth failed:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -115,10 +272,11 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={dropdownRef} className="cryptid-dropdown" style={{ position: 'relative' }}>
|
<div ref={dropdownRef} className="cryptid-dropdown" style={{ position: 'relative', pointerEvents: 'all' }}>
|
||||||
{/* Trigger button */}
|
{/* Trigger button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDropdown(!showDropdown)}
|
onClick={() => setShowDropdown(!showDropdown)}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
className="cryptid-trigger"
|
className="cryptid-trigger"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -131,6 +289,7 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: 'var(--color-text-1)',
|
color: 'var(--color-text-1)',
|
||||||
transition: 'background 0.15s',
|
transition: 'background 0.15s',
|
||||||
|
pointerEvents: 'all',
|
||||||
}}
|
}}
|
||||||
title={session.authed ? session.username : 'Sign in with CryptID'}
|
title={session.authed ? session.username : 'Sign in with CryptID'}
|
||||||
>
|
>
|
||||||
|
|
@ -179,13 +338,23 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
top: 'calc(100% + 8px)',
|
top: 'calc(100% + 8px)',
|
||||||
right: 0,
|
right: 0,
|
||||||
minWidth: '260px',
|
minWidth: '260px',
|
||||||
|
maxHeight: 'calc(100vh - 100px)',
|
||||||
background: 'var(--color-panel)',
|
background: 'var(--color-panel)',
|
||||||
border: '1px solid var(--color-panel-contrast)',
|
border: '1px solid var(--color-panel-contrast)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||||||
zIndex: 100000,
|
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 ? (
|
{session.authed ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -219,6 +388,44 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Integrations section */}
|
||||||
<div style={{ padding: '8px 0' }}>
|
<div style={{ padding: '8px 0' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|
@ -275,31 +482,36 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
setShowGoogleBrowser(true);
|
setShowGoogleBrowser(true);
|
||||||
setShowDropdown(false);
|
setShowDropdown(false);
|
||||||
}}
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: '6px 12px',
|
padding: '8px 14px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
fontWeight: 500,
|
fontWeight: 600,
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
backgroundColor: '#4285F4',
|
background: 'linear-gradient(135deg, #4285F4, #34A853)',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
boxShadow: '0 2px 8px rgba(66, 133, 244, 0.3)',
|
||||||
|
pointerEvents: 'all',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Browse Data
|
Browse Data
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleGoogleDisconnect}
|
onClick={handleGoogleDisconnect}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 12px',
|
padding: '8px 14px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
border: '1px solid var(--color-panel-contrast)',
|
border: '1px solid var(--color-panel-contrast)',
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'var(--color-muted-2)',
|
||||||
color: 'var(--color-text-3)',
|
color: 'var(--color-text)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
pointerEvents: 'all',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Disconnect
|
Disconnect
|
||||||
|
|
@ -308,18 +520,30 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={handleGoogleConnect}
|
onClick={handleGoogleConnect}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
disabled={googleLoading}
|
disabled={googleLoading}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: '6px 12px',
|
padding: '8px 16px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
fontWeight: 500,
|
fontWeight: 600,
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
border: '1px solid var(--color-panel-contrast)',
|
border: 'none',
|
||||||
backgroundColor: 'transparent',
|
background: 'linear-gradient(135deg, #4285F4, #34A853)',
|
||||||
color: 'var(--color-text)',
|
color: 'white',
|
||||||
cursor: googleLoading ? 'wait' : 'pointer',
|
cursor: googleLoading ? 'wait' : 'pointer',
|
||||||
opacity: googleLoading ? 0.7 : 1,
|
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'}
|
{googleLoading ? 'Connecting...' : 'Connect Google'}
|
||||||
|
|
@ -327,6 +551,250 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Sign out */}
|
{/* Sign out */}
|
||||||
|
|
@ -410,6 +878,240 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
isDarkMode={isDarkMode}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { VersionHistoryPanel } from './VersionHistoryPanel';
|
||||||
|
export { useVersionHistory } from './useVersionHistory';
|
||||||
|
export type { HistoryEntry, SnapshotDiff, UseVersionHistoryReturn } from './useVersionHistory';
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -29,7 +29,7 @@ interface NetworkGraphMinimapProps {
|
||||||
edges: GraphEdge[];
|
edges: GraphEdge[];
|
||||||
myConnections: string[];
|
myConnections: string[];
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
onConnect: (userId: string) => Promise<void>;
|
onConnect: (userId: string, trustLevel?: TrustLevel) => Promise<void>;
|
||||||
onDisconnect?: (connectionId: string) => Promise<void>;
|
onDisconnect?: (connectionId: string) => Promise<void>;
|
||||||
onNodeClick?: (node: GraphNode) => void;
|
onNodeClick?: (node: GraphNode) => void;
|
||||||
onEdgeClick?: (edge: GraphEdge) => void;
|
onEdgeClick?: (edge: GraphEdge) => void;
|
||||||
|
|
@ -38,6 +38,7 @@ interface NetworkGraphMinimapProps {
|
||||||
height?: number;
|
height?: number;
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
onToggleCollapse?: () => void;
|
onToggleCollapse?: () => void;
|
||||||
|
isDarkMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SimulationNode extends d3.SimulationNodeDatum, GraphNode {}
|
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: {
|
container: {
|
||||||
position: 'fixed' as const,
|
position: 'fixed' as const,
|
||||||
bottom: '60px',
|
bottom: '60px',
|
||||||
|
|
@ -62,12 +63,12 @@ const styles = {
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
},
|
},
|
||||||
panel: {
|
panel: {
|
||||||
backgroundColor: 'rgba(20, 20, 25, 0.95)',
|
backgroundColor: isDarkMode ? 'rgba(20, 20, 25, 0.95)' : 'rgba(255, 255, 255, 0.98)',
|
||||||
borderRadius: '12px',
|
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',
|
overflow: 'hidden',
|
||||||
transition: 'all 0.2s ease',
|
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: {
|
panelCollapsed: {
|
||||||
width: '48px',
|
width: '48px',
|
||||||
|
|
@ -82,13 +83,13 @@ const styles = {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
|
borderBottom: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
backgroundColor: isDarkMode ? 'rgba(255, 255, 255, 0.03)' : 'rgba(0, 0, 0, 0.02)',
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: '#e0e0e0',
|
color: isDarkMode ? '#e0e0e0' : '#374151',
|
||||||
margin: 0,
|
margin: 0,
|
||||||
},
|
},
|
||||||
headerButtons: {
|
headerButtons: {
|
||||||
|
|
@ -106,16 +107,17 @@ const styles = {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: '#a0a0a0',
|
color: isDarkMode ? '#a0a0a0' : '#6b7280',
|
||||||
transition: 'background-color 0.15s, color 0.15s',
|
transition: 'background-color 0.15s, color 0.15s',
|
||||||
},
|
},
|
||||||
canvas: {
|
canvas: {
|
||||||
display: 'block',
|
display: 'block',
|
||||||
|
backgroundColor: isDarkMode ? 'transparent' : 'rgba(249, 250, 251, 0.5)',
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
position: 'absolute' as const,
|
position: 'absolute' as const,
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
backgroundColor: isDarkMode ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.95)',
|
||||||
color: '#fff',
|
color: isDarkMode ? '#fff' : '#1f2937',
|
||||||
padding: '6px 10px',
|
padding: '6px 10px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
|
|
@ -124,6 +126,8 @@ const styles = {
|
||||||
zIndex: 1001,
|
zIndex: 1001,
|
||||||
transform: 'translate(-50%, -100%)',
|
transform: 'translate(-50%, -100%)',
|
||||||
marginTop: '-8px',
|
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: {
|
collapsedIcon: {
|
||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
|
|
@ -132,10 +136,10 @@ const styles = {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '12px',
|
gap: '12px',
|
||||||
padding: '6px 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',
|
fontSize: '11px',
|
||||||
color: '#888',
|
color: isDarkMode ? '#888' : '#6b7280',
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
backgroundColor: isDarkMode ? 'rgba(0, 0, 0, 0.2)' : 'rgba(0, 0, 0, 0.02)',
|
||||||
},
|
},
|
||||||
stat: {
|
stat: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -147,7 +151,7 @@ const styles = {
|
||||||
height: '8px',
|
height: '8px',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Component
|
// Component
|
||||||
|
|
@ -167,6 +171,7 @@ export function NetworkGraphMinimap({
|
||||||
height = 180,
|
height = 180,
|
||||||
isCollapsed = false,
|
isCollapsed = false,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
|
isDarkMode = false,
|
||||||
}: NetworkGraphMinimapProps) {
|
}: NetworkGraphMinimapProps) {
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(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 [isConnecting, setIsConnecting] = useState(false);
|
||||||
const simulationRef = useRef<d3.Simulation<SimulationNode, SimulationLink> | null>(null);
|
const simulationRef = useRef<d3.Simulation<SimulationNode, SimulationLink> | null>(null);
|
||||||
|
|
||||||
|
// Get theme-aware styles
|
||||||
|
const styles = React.useMemo(() => getStyles(isDarkMode), [isDarkMode]);
|
||||||
|
|
||||||
// Count stats
|
// Count stats
|
||||||
const inRoomCount = nodes.filter(n => n.isInRoom).length;
|
const inRoomCount = nodes.filter(n => n.isInRoom).length;
|
||||||
const anonymousCount = nodes.filter(n => n.isAnonymous).length;
|
const anonymousCount = nodes.filter(n => n.isAnonymous).length;
|
||||||
|
|
@ -202,14 +210,18 @@ export function NetworkGraphMinimap({
|
||||||
isMutual: e.isMutual,
|
isMutual: e.isMutual,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Create the simulation
|
// Create the simulation with faster decay for stabilization
|
||||||
const simulation = d3.forceSimulation<SimulationNode>(simNodes)
|
const simulation = d3.forceSimulation<SimulationNode>(simNodes)
|
||||||
.force('link', d3.forceLink<SimulationNode, SimulationLink>(simLinks)
|
.force('link', d3.forceLink<SimulationNode, SimulationLink>(simLinks)
|
||||||
.id(d => d.id)
|
.id(d => d.id)
|
||||||
.distance(40))
|
.distance(40))
|
||||||
.force('charge', d3.forceManyBody().strength(-80))
|
.force('charge', d3.forceManyBody().strength(-80))
|
||||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
.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;
|
simulationRef.current = simulation;
|
||||||
|
|
||||||
|
|
@ -404,6 +416,12 @@ export function NetworkGraphMinimap({
|
||||||
.attr('cy', d => Math.max(8, Math.min(height - 8, d.y!)));
|
.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 () => {
|
return () => {
|
||||||
simulation.stop();
|
simulation.stop();
|
||||||
};
|
};
|
||||||
|
|
@ -573,7 +591,9 @@ export function NetworkGraphMinimap({
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to connect:', err);
|
console.error('Failed to connect:', err);
|
||||||
}
|
}
|
||||||
|
|
@ -597,9 +617,10 @@ export function NetworkGraphMinimap({
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
try {
|
try {
|
||||||
// Connect with trusted level
|
// Use username for API call (CryptID username), not tldraw session id
|
||||||
await onConnect(selectedNode.node.id);
|
// Connect with trusted level directly
|
||||||
// Then upgrade - would need separate call
|
const userId = selectedNode.node.username || selectedNode.node.id;
|
||||||
|
await onConnect(userId, 'trusted');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to connect:', err);
|
console.error('Failed to connect:', err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,24 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const [selectedEdge, setSelectedEdge] = useState<GraphEdge | null>(null);
|
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
|
// Get collaborators from tldraw
|
||||||
const collaborators = useValue(
|
const collaborators = useValue(
|
||||||
'collaborators',
|
'collaborators',
|
||||||
|
|
@ -78,9 +96,9 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
|
||||||
useCache: true,
|
useCache: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle connect with default trust level
|
// Handle connect with optional trust level
|
||||||
const handleConnect = useCallback(async (userId: string) => {
|
const handleConnect = useCallback(async (userId: string, trustLevel: TrustLevel = 'connected') => {
|
||||||
await connect(userId);
|
await connect(userId, trustLevel);
|
||||||
}, [connect]);
|
}, [connect]);
|
||||||
|
|
||||||
// Handle disconnect
|
// Handle disconnect
|
||||||
|
|
@ -142,6 +160,7 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
|
||||||
onExpandClick={handleExpand}
|
onExpandClick={handleExpand}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
|
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
type NetworkGraph,
|
type NetworkGraph,
|
||||||
type GraphNode,
|
type GraphNode,
|
||||||
type GraphEdge,
|
type GraphEdge,
|
||||||
|
type TrustLevel,
|
||||||
} from '../../lib/networking';
|
} from '../../lib/networking';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -53,8 +54,8 @@ export interface UseNetworkGraphOptions {
|
||||||
export interface UseNetworkGraphReturn extends NetworkGraphState {
|
export interface UseNetworkGraphReturn extends NetworkGraphState {
|
||||||
// Refresh the graph from the server
|
// Refresh the graph from the server
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
// Connect to a user
|
// Connect to a user with optional trust level
|
||||||
connect: (userId: string) => Promise<void>;
|
connect: (userId: string, trustLevel?: TrustLevel) => Promise<void>;
|
||||||
// Disconnect from a user
|
// Disconnect from a user
|
||||||
disconnect: (connectionId: string) => Promise<void>;
|
disconnect: (connectionId: string) => Promise<void>;
|
||||||
// Check if connected to a user
|
// Check if connected to a user
|
||||||
|
|
@ -267,9 +268,9 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
|
||||||
}, [participantIds, participantColorMap]);
|
}, [participantIds, participantColorMap]);
|
||||||
|
|
||||||
// Connect to a user
|
// Connect to a user
|
||||||
const connect = useCallback(async (userId: string) => {
|
const connect = useCallback(async (userId: string, trustLevel: TrustLevel = 'connected') => {
|
||||||
try {
|
try {
|
||||||
await createConnection(userId);
|
await createConnection(userId, trustLevel);
|
||||||
// Refresh the graph to get updated state
|
// Refresh the graph to get updated state
|
||||||
await fetchGraph(true);
|
await fetchGraph(true);
|
||||||
clearGraphCache();
|
clearGraphCache();
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ interface AuthContextType {
|
||||||
canEdit: () => boolean;
|
canEdit: () => boolean;
|
||||||
/** Check if user is admin for the current board */
|
/** Check if user is admin for the current board */
|
||||||
isAdmin: () => boolean;
|
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 = {
|
const initialSession: Session = {
|
||||||
|
|
@ -35,6 +39,22 @@ export const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||||
|
|
||||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
const [session, setSessionState] = useState<Session>(initialSession);
|
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
|
// Update session with partial data
|
||||||
const setSession = useCallback((updatedSession: Partial<Session>) => {
|
const setSession = useCallback((updatedSession: Partial<Session>) => {
|
||||||
|
|
@ -175,12 +195,26 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
}
|
}
|
||||||
}, [clearSession]);
|
}, [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
|
* 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> => {
|
const fetchBoardPermission = useCallback(async (boardId: string): Promise<PermissionLevel> => {
|
||||||
// Check cache first
|
// Check cache first (but only if no access token - token changes permissions)
|
||||||
if (session.boardPermissions?.[boardId]) {
|
if (!accessToken && session.boardPermissions?.[boardId]) {
|
||||||
return 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',
|
method: 'GET',
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('Failed to fetch board permission:', response.status);
|
console.error('Failed to fetch board permission:', response.status);
|
||||||
// Default to 'view' for unauthenticated, 'edit' for authenticated
|
// Default to 'view' for unauthenticated (secure by default)
|
||||||
return session.authed ? 'edit' : 'view';
|
return 'view';
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json() as {
|
const data = await response.json() as {
|
||||||
permission: PermissionLevel;
|
permission: PermissionLevel;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
boardExists: boolean;
|
boardExists: boolean;
|
||||||
|
grantedByToken?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (data.grantedByToken) {
|
||||||
|
console.log('🔓 Permission granted via access token:', data.permission);
|
||||||
|
}
|
||||||
|
|
||||||
// Cache the permission
|
// Cache the permission
|
||||||
setSessionState(prev => ({
|
setSessionState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -227,10 +273,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
return data.permission;
|
return data.permission;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching board permission:', error);
|
console.error('Error fetching board permission:', error);
|
||||||
// Default to 'view' for unauthenticated, 'edit' for authenticated
|
// Default to 'view' (secure by default)
|
||||||
return session.authed ? 'edit' : 'view';
|
return 'view';
|
||||||
}
|
}
|
||||||
}, [session.authed, session.username, session.boardPermissions]);
|
}, [session.authed, session.username, session.boardPermissions, accessToken]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user can edit the current board
|
* Check if user can edit the current board
|
||||||
|
|
@ -278,7 +324,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
fetchBoardPermission,
|
fetchBoardPermission,
|
||||||
canEdit,
|
canEdit,
|
||||||
isAdmin,
|
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 (
|
return (
|
||||||
<AuthContext.Provider value={contextValue}>
|
<AuthContext.Provider value={contextValue}>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import { Editor } from 'tldraw'
|
import { Editor, TLShapeId } from 'tldraw'
|
||||||
|
|
||||||
interface OriginalDimensions {
|
interface OriginalDimensions {
|
||||||
x: number
|
x: number
|
||||||
|
|
@ -12,7 +12,7 @@ interface UseMaximizeOptions {
|
||||||
/** Editor instance */
|
/** Editor instance */
|
||||||
editor: Editor
|
editor: Editor
|
||||||
/** Shape ID to maximize */
|
/** Shape ID to maximize */
|
||||||
shapeId: string
|
shapeId: TLShapeId
|
||||||
/** Current width of the shape */
|
/** Current width of the shape */
|
||||||
currentW: number
|
currentW: number
|
||||||
/** Current height of the shape */
|
/** Current height of the shape */
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export interface Session {
|
||||||
authed: boolean;
|
authed: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
backupCreated: boolean | null;
|
backupCreated: boolean | null;
|
||||||
|
email?: string; // Email for account backup
|
||||||
obsidianVaultPath?: string;
|
obsidianVaultPath?: string;
|
||||||
obsidianVaultName?: string;
|
obsidianVaultName?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
|
||||||
|
|
@ -30,13 +30,41 @@ const API_BASE = '/api/networking';
|
||||||
// Helper Functions
|
// 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> {
|
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, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options?.headers,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -948,28 +948,33 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input Section */}
|
{/* Input Section - Mobile Optimized */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
flexDirection: shape.props.w < 350 ? "column" : "row",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
padding: "4px 0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<textarea
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: "36px",
|
minHeight: "48px",
|
||||||
|
height: shape.props.w < 350 ? "60px" : "48px",
|
||||||
backgroundColor: "#fff",
|
backgroundColor: "#fff",
|
||||||
border: "1px solid #ddd",
|
border: "1px solid #ddd",
|
||||||
borderRadius: "6px",
|
borderRadius: "8px",
|
||||||
fontSize: 13,
|
fontSize: 14,
|
||||||
padding: "0 10px",
|
padding: "12px",
|
||||||
touchAction: "manipulation",
|
touchAction: "manipulation",
|
||||||
minHeight: "44px",
|
resize: "none",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
lineHeight: "1.4",
|
||||||
|
WebkitAppearance: "none",
|
||||||
}}
|
}}
|
||||||
type="text"
|
placeholder="Describe the image you want to generate..."
|
||||||
placeholder="Enter image prompt..."
|
|
||||||
value={shape.props.prompt}
|
value={shape.props.prompt}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
editor.updateShape<IImageGen>({
|
editor.updateShape<IImageGen>({
|
||||||
|
|
@ -1000,20 +1005,26 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
style={{
|
style={{
|
||||||
height: "36px",
|
height: shape.props.w < 350 ? "48px" : "48px",
|
||||||
padding: "0 16px",
|
padding: "0 20px",
|
||||||
pointerEvents: "all",
|
pointerEvents: "all",
|
||||||
cursor: shape.props.prompt.trim() && !shape.props.isLoading ? "pointer" : "not-allowed",
|
cursor: shape.props.prompt.trim() && !shape.props.isLoading ? "pointer" : "not-allowed",
|
||||||
backgroundColor: shape.props.prompt.trim() && !shape.props.isLoading ? ImageGenShape.PRIMARY_COLOR : "#ccc",
|
backgroundColor: shape.props.prompt.trim() && !shape.props.isLoading ? ImageGenShape.PRIMARY_COLOR : "#ccc",
|
||||||
color: "white",
|
color: "white",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "6px",
|
borderRadius: "8px",
|
||||||
fontWeight: "500",
|
fontWeight: "600",
|
||||||
fontSize: "13px",
|
fontSize: "14px",
|
||||||
opacity: shape.props.prompt.trim() && !shape.props.isLoading ? 1 : 0.6,
|
opacity: shape.props.prompt.trim() && !shape.props.isLoading ? 1 : 0.6,
|
||||||
touchAction: "manipulation",
|
touchAction: "manipulation",
|
||||||
minWidth: "44px",
|
minWidth: shape.props.w < 350 ? "100%" : "100px",
|
||||||
minHeight: "44px",
|
minHeight: "48px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "6px",
|
||||||
|
transition: "background-color 0.15s, transform 0.1s",
|
||||||
|
WebkitTapHighlightColor: "transparent",
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
@ -1024,10 +1035,13 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
||||||
}}
|
}}
|
||||||
onTouchStart={(e) => {
|
onTouchStart={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
// Visual feedback on touch
|
||||||
|
e.currentTarget.style.transform = "scale(0.98)"
|
||||||
}}
|
}}
|
||||||
onTouchEnd={(e) => {
|
onTouchEnd={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
e.currentTarget.style.transform = "scale(1)"
|
||||||
if (shape.props.prompt.trim() && !shape.props.isLoading) {
|
if (shape.props.prompt.trim() && !shape.props.isLoading) {
|
||||||
handleGenerate()
|
handleGenerate()
|
||||||
}
|
}
|
||||||
|
|
@ -1041,6 +1055,7 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
||||||
}}
|
}}
|
||||||
disabled={shape.props.isLoading || !shape.props.prompt.trim()}
|
disabled={shape.props.isLoading || !shape.props.prompt.trim()}
|
||||||
>
|
>
|
||||||
|
<span style={{ fontSize: "16px" }}>✨</span>
|
||||||
Generate
|
Generate
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,77 @@ const DEFAULT_VIEWPORT: MapViewport = {
|
||||||
|
|
||||||
const OSRM_BASE_URL = 'https://routing.jeffemmett.com';
|
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
|
// Mapus color palette
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
'#E15F59', // Red
|
'#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 activeToolRef = useRef(activeTool); // Ref to track current tool in event handlers
|
||||||
const [selectedColor, setSelectedColor] = useState(COLORS[4]);
|
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(() => {
|
useEffect(() => {
|
||||||
activeToolRef.current = activeTool;
|
activeToolRef.current = activeTool;
|
||||||
|
// Clear drawing points when switching tools
|
||||||
|
if (activeTool !== 'line' && activeTool !== 'area') {
|
||||||
|
setDrawingPoints([]);
|
||||||
|
drawingPointsRef.current = [];
|
||||||
|
}
|
||||||
}, [activeTool]);
|
}, [activeTool]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
drawingPointsRef.current = drawingPoints;
|
||||||
|
}, [drawingPoints]);
|
||||||
|
|
||||||
const [showColorPicker, setShowColorPicker] = useState(false);
|
const [showColorPicker, setShowColorPicker] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||||
|
|
@ -418,24 +503,75 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
|
||||||
if (!isMountedRef.current) return;
|
if (!isMountedRef.current) return;
|
||||||
const coord = { lat: e.lngLat.lat, lng: e.lngLat.lng };
|
const coord = { lat: e.lngLat.lat, lng: e.lngLat.lng };
|
||||||
const currentTool = activeToolRef.current;
|
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') {
|
if (currentTool === 'marker') {
|
||||||
addAnnotation('marker', [coord]);
|
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('load', handleLoad);
|
||||||
map.on('moveend', handleMoveEnd);
|
map.on('moveend', handleMoveEnd);
|
||||||
map.on('click', handleClick);
|
map.on('click', handleClick);
|
||||||
|
map.on('dblclick', handleDblClick);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Remove event listeners before destroying map
|
// Remove event listeners before destroying map
|
||||||
map.off('load', handleLoad);
|
map.off('load', handleLoad);
|
||||||
map.off('moveend', handleMoveEnd);
|
map.off('moveend', handleMoveEnd);
|
||||||
map.off('click', handleClick);
|
map.off('click', handleClick);
|
||||||
|
map.off('dblclick', handleDblClick);
|
||||||
|
|
||||||
// Clear all markers
|
// Clear all markers
|
||||||
markersRef.current.forEach((marker) => {
|
markersRef.current.forEach((marker) => {
|
||||||
|
|
@ -576,8 +712,255 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
|
||||||
markersRef.current.delete(id);
|
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]);
|
}, [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)
|
// Collaborator presence (cursors/locations)
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
@ -1121,8 +1504,20 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: ann.color,
|
background: ann.color,
|
||||||
}} />
|
}} />
|
||||||
<div style={{ flex: 1, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
{ann.name}
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); toggleAnnotationVisibility(ann.id); }}
|
onClick={(e) => { e.stopPropagation(); toggleAnnotationVisibility(ann.id); }}
|
||||||
|
|
@ -1273,6 +1668,43 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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) */}
|
{/* Drawing Toolbar (Mapus-style) */}
|
||||||
<div style={styles.toolbar} onPointerDown={stopPropagation}>
|
<div style={styles.toolbar} onPointerDown={stopPropagation}>
|
||||||
{/* Cursor Tool */}
|
{/* Cursor Tool */}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@ import { useDialogs } from "tldraw"
|
||||||
import { SettingsDialog } from "./SettingsDialog"
|
import { SettingsDialog } from "./SettingsDialog"
|
||||||
import { useAuth } from "../context/AuthContext"
|
import { useAuth } from "../context/AuthContext"
|
||||||
import LoginButton from "../components/auth/LoginButton"
|
import LoginButton from "../components/auth/LoginButton"
|
||||||
import StarBoardButton from "../components/StarBoardButton"
|
|
||||||
import ShareBoardButton from "../components/ShareBoardButton"
|
|
||||||
import { ObsidianVaultBrowser } from "../components/ObsidianVaultBrowser"
|
import { ObsidianVaultBrowser } from "../components/ObsidianVaultBrowser"
|
||||||
import { HolonBrowser } from "../components/HolonBrowser"
|
import { HolonBrowser } from "../components/HolonBrowser"
|
||||||
import { ObsNoteShape } from "../shapes/ObsNoteShapeUtil"
|
import { ObsNoteShape } from "../shapes/ObsNoteShapeUtil"
|
||||||
|
|
@ -645,790 +643,7 @@ export function CustomToolbar() {
|
||||||
if (!isReady) return null
|
if (!isReady) return null
|
||||||
|
|
||||||
return (
|
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>
|
<DefaultToolbar>
|
||||||
<DefaultToolbarContent />
|
<DefaultToolbarContent />
|
||||||
{tools["VideoChat"] && (
|
{tools["VideoChat"] && (
|
||||||
|
|
@ -1579,7 +794,6 @@ export function CustomToolbar() {
|
||||||
onClose={() => setShowFathomPanel(false)}
|
onClose={() => setShowFathomPanel(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,24 +16,48 @@ interface InviteDialogProps extends TLUiDialogProps {
|
||||||
boardSlug: string
|
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) {
|
export function InviteDialog({ onClose, boardUrl, boardSlug }: InviteDialogProps) {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('qr')
|
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [nfcStatus, setNfcStatus] = useState<'idle' | 'writing' | 'success' | 'error' | 'unsupported'>('idle')
|
const [nfcStatus, setNfcStatus] = useState<'idle' | 'writing' | 'success' | 'error' | 'unsupported'>('idle')
|
||||||
const [nfcMessage, setNfcMessage] = useState('')
|
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(() => {
|
useEffect(() => {
|
||||||
if (!('NDEFReader' in window)) {
|
if (!('NDEFReader' in window)) {
|
||||||
setNfcStatus('unsupported')
|
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 () => {
|
const handleCopyUrl = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(boardUrl)
|
await navigator.clipboard.writeText(getShareUrl())
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -55,7 +79,7 @@ export function InviteDialog({ onClose, boardUrl, boardSlug }: InviteDialogProps
|
||||||
const ndef = new (window as any).NDEFReader()
|
const ndef = new (window as any).NDEFReader()
|
||||||
await ndef.write({
|
await ndef.write({
|
||||||
records: [
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<TldrawUiDialogHeader>
|
<TldrawUiDialogHeader>
|
||||||
<TldrawUiDialogTitle>Invite to Board</TldrawUiDialogTitle>
|
<TldrawUiDialogTitle>Invite to Board</TldrawUiDialogTitle>
|
||||||
<TldrawUiDialogCloseButton />
|
<TldrawUiDialogCloseButton />
|
||||||
</TldrawUiDialogHeader>
|
</TldrawUiDialogHeader>
|
||||||
<TldrawUiDialogBody style={{ maxWidth: 420, minHeight: 380 }}>
|
<TldrawUiDialogBody style={{ maxWidth: 420 }}>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
{/* Board name display */}
|
{/* Board name display */}
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|
@ -111,228 +122,230 @@ export function InviteDialog({ onClose, boardUrl, boardSlug }: InviteDialogProps
|
||||||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#1e293b' }}>{boardSlug}</span>
|
<span style={{ fontSize: '14px', fontWeight: 600, color: '#1e293b' }}>{boardSlug}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab navigation */}
|
{/* Permission selector */}
|
||||||
<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 */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
minHeight: 220,
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
gap: '8px',
|
||||||
justifyContent: 'center',
|
}}>
|
||||||
|
<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',
|
padding: '16px',
|
||||||
backgroundColor: '#fafafa',
|
backgroundColor: '#fafafa',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
border: '1px solid #e5e7eb'
|
border: '1px solid #e5e7eb'
|
||||||
}}>
|
}}>
|
||||||
{activeTab === 'qr' && (
|
{/* QR Code */}
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{
|
||||||
<div style={{
|
padding: '12px',
|
||||||
padding: '16px',
|
backgroundColor: 'white',
|
||||||
backgroundColor: 'white',
|
borderRadius: '8px',
|
||||||
borderRadius: '12px',
|
boxShadow: '0 1px 4px rgba(0,0,0,0.1)',
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
flexShrink: 0,
|
||||||
display: 'inline-block'
|
}}>
|
||||||
}}>
|
<QRCodeSVG
|
||||||
<QRCodeSVG
|
value={getShareUrl()}
|
||||||
value={boardUrl}
|
size={120}
|
||||||
size={180}
|
level="M"
|
||||||
level="M"
|
includeMargin={false}
|
||||||
includeMargin={false}
|
/>
|
||||||
/>
|
</div>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'url' && (
|
{/* URL and Copy Button */}
|
||||||
<div style={{ width: '100%', textAlign: 'center' }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: '12px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '12px',
|
padding: '10px',
|
||||||
backgroundColor: 'white',
|
backgroundColor: 'white',
|
||||||
borderRadius: '8px',
|
borderRadius: '6px',
|
||||||
border: '1px solid #d1d5db',
|
border: '1px solid #d1d5db',
|
||||||
marginBottom: '16px',
|
wordBreak: 'break-all',
|
||||||
wordBreak: 'break-all',
|
fontSize: '11px',
|
||||||
fontSize: '13px',
|
fontFamily: 'monospace',
|
||||||
fontFamily: 'monospace',
|
color: '#374151',
|
||||||
color: '#374151'
|
lineHeight: 1.4,
|
||||||
}}>
|
maxHeight: '60px',
|
||||||
{boardUrl}
|
overflowY: 'auto',
|
||||||
</div>
|
}}>
|
||||||
<button
|
{getShareUrl()}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<button
|
||||||
|
onClick={handleCopyUrl}
|
||||||
{activeTab === 'nfc' && (
|
style={{
|
||||||
<div style={{ width: '100%', textAlign: 'center' }}>
|
padding: '8px 16px',
|
||||||
{nfcStatus === 'unsupported' ? (
|
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={{
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
fontSize: '48px',
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
marginBottom: '16px',
|
</svg>
|
||||||
opacity: 0.5
|
<span>Copied!</span>
|
||||||
}}>
|
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div style={{
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
fontSize: '48px',
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
marginBottom: '16px',
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
animation: nfcStatus === 'writing' ? 'pulse 1.5s infinite' : 'none'
|
</svg>
|
||||||
}}>
|
<span>Copy Link</span>
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
)}
|
</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>
|
|
||||||
)}
|
|
||||||
</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>
|
</div>
|
||||||
</TldrawUiDialogBody>
|
</TldrawUiDialogBody>
|
||||||
<TldrawUiDialogFooter>
|
<TldrawUiDialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import { UserSettingsModal } from "./UserSettingsModal"
|
||||||
import { NetworkGraphPanel } from "../components/networking"
|
import { NetworkGraphPanel } from "../components/networking"
|
||||||
import CryptIDDropdown from "../components/auth/CryptIDDropdown"
|
import CryptIDDropdown from "../components/auth/CryptIDDropdown"
|
||||||
import StarBoardButton from "../components/StarBoardButton"
|
import StarBoardButton from "../components/StarBoardButton"
|
||||||
|
import ShareBoardButton from "../components/ShareBoardButton"
|
||||||
|
import { SettingsDialog } from "./SettingsDialog"
|
||||||
import {
|
import {
|
||||||
DefaultKeyboardShortcutsDialog,
|
DefaultKeyboardShortcutsDialog,
|
||||||
DefaultKeyboardShortcutsDialogContent,
|
DefaultKeyboardShortcutsDialogContent,
|
||||||
|
|
@ -16,22 +18,69 @@ import {
|
||||||
TldrawUiMenuItem,
|
TldrawUiMenuItem,
|
||||||
useTools,
|
useTools,
|
||||||
useActions,
|
useActions,
|
||||||
|
useDialogs,
|
||||||
} from "tldraw"
|
} from "tldraw"
|
||||||
import { SlidesPanel } from "@/slides/SlidesPanel"
|
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
|
// Custom SharePanel with layout: CryptID -> Star -> Gear -> Question mark
|
||||||
function CustomSharePanel() {
|
function CustomSharePanel() {
|
||||||
const tools = useTools()
|
const tools = useTools()
|
||||||
const actions = useActions()
|
const actions = useActions()
|
||||||
|
const { addDialog, removeDialog } = useDialogs()
|
||||||
const [showShortcuts, setShowShortcuts] = React.useState(false)
|
const [showShortcuts, setShowShortcuts] = React.useState(false)
|
||||||
const [showSettings, setShowSettings] = React.useState(false)
|
const [showSettings, setShowSettings] = React.useState(false)
|
||||||
const [showSettingsDropdown, setShowSettingsDropdown] = 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
|
// Detect dark mode - use state to trigger re-render on change
|
||||||
const [isDarkMode, setIsDarkMode] = React.useState(
|
const [isDarkMode, setIsDarkMode] = React.useState(
|
||||||
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
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 handleToggleDarkMode = () => {
|
||||||
const newIsDark = !document.documentElement.classList.contains('dark')
|
const newIsDark = !document.documentElement.classList.contains('dark')
|
||||||
document.documentElement.classList.toggle('dark')
|
document.documentElement.classList.toggle('dark')
|
||||||
|
|
@ -39,6 +88,31 @@ function CustomSharePanel() {
|
||||||
setIsDarkMode(newIsDark)
|
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)
|
// Helper to extract label string from tldraw label (can be string or {default, menu} object)
|
||||||
const getLabelString = (label: any, fallback: string): string => {
|
const getLabelString = (label: any, fallback: string): string => {
|
||||||
if (typeof label === 'string') return label
|
if (typeof label === 'string') return label
|
||||||
|
|
@ -147,6 +221,13 @@ function CustomSharePanel() {
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* Share board button */}
|
||||||
|
<div style={{ padding: '0 2px' }}>
|
||||||
|
<ShareBoardButton className="share-panel-btn" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
{/* Star board button */}
|
{/* Star board button */}
|
||||||
<div style={{ padding: '0 2px' }}>
|
<div style={{ padding: '0 2px' }}>
|
||||||
<StarBoardButton className="share-panel-btn" />
|
<StarBoardButton className="share-panel-btn" />
|
||||||
|
|
@ -194,27 +275,36 @@ function CustomSharePanel() {
|
||||||
{/* Settings dropdown */}
|
{/* Settings dropdown */}
|
||||||
{showSettingsDropdown && (
|
{showSettingsDropdown && (
|
||||||
<>
|
<>
|
||||||
|
{/* Backdrop - only uses onClick, not onPointerDown */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
zIndex: 99998,
|
zIndex: 99998,
|
||||||
|
background: 'transparent',
|
||||||
}}
|
}}
|
||||||
onClick={() => setShowSettingsDropdown(false)}
|
onClick={() => setShowSettingsDropdown(false)}
|
||||||
/>
|
/>
|
||||||
|
{/* Dropdown menu */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 'calc(100% + 8px)',
|
top: 'calc(100% + 8px)',
|
||||||
right: 0,
|
right: 0,
|
||||||
minWidth: '200px',
|
minWidth: '200px',
|
||||||
|
maxHeight: '60vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
background: 'var(--color-panel)',
|
background: 'var(--color-panel)',
|
||||||
border: '1px solid var(--color-panel-contrast)',
|
border: '1px solid var(--color-panel-contrast)',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
|
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
|
||||||
zIndex: 99999,
|
zIndex: 99999,
|
||||||
padding: '8px 0',
|
padding: '8px 0',
|
||||||
|
pointerEvents: 'auto',
|
||||||
}}
|
}}
|
||||||
|
onWheel={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Dark mode toggle */}
|
{/* Dark mode toggle */}
|
||||||
<button
|
<button
|
||||||
|
|
@ -259,6 +349,103 @@ function CustomSharePanel() {
|
||||||
|
|
||||||
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '4px 0' }} />
|
<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 */}
|
{/* All settings */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -341,29 +528,36 @@ function CustomSharePanel() {
|
||||||
{/* Keyboard shortcuts panel */}
|
{/* Keyboard shortcuts panel */}
|
||||||
{showShortcuts && (
|
{showShortcuts && (
|
||||||
<>
|
<>
|
||||||
|
{/* Backdrop - only uses onClick, not onPointerDown */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
zIndex: 99998,
|
zIndex: 99998,
|
||||||
|
background: 'transparent',
|
||||||
}}
|
}}
|
||||||
onClick={() => setShowShortcuts(false)}
|
onClick={() => setShowShortcuts(false)}
|
||||||
/>
|
/>
|
||||||
|
{/* Shortcuts menu */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 'calc(100% + 8px)',
|
top: 'calc(100% + 8px)',
|
||||||
right: 0,
|
right: 0,
|
||||||
width: '320px',
|
width: '320px',
|
||||||
maxHeight: '70vh',
|
maxHeight: '60vh',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
background: 'var(--color-panel)',
|
background: 'var(--color-panel)',
|
||||||
border: '1px solid var(--color-panel-contrast)',
|
border: '1px solid var(--color-panel-contrast)',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
|
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
|
||||||
zIndex: 99999,
|
zIndex: 99999,
|
||||||
padding: '12px 0',
|
padding: '12px 0',
|
||||||
|
pointerEvents: 'auto',
|
||||||
}}
|
}}
|
||||||
|
onWheel={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '8px 16px 12px',
|
padding: '8px 16px 12px',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/open-mapping/**/*"],
|
||||||
|
"exclude": []
|
||||||
|
}
|
||||||
|
|
@ -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` is the entry point for all requests to the Durable Object
|
||||||
fetch(request: Request): Response | Promise<Response> {
|
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
|
// Generate a fast hash of the document state for change detection
|
||||||
// OPTIMIZED: Instead of JSON.stringify on entire document (expensive for large docs),
|
// OPTIMIZED: Instead of JSON.stringify on entire document (expensive for large docs),
|
||||||
// we hash based on record IDs, types, and metadata only
|
// we hash based on record IDs, types, and metadata only
|
||||||
|
|
|
||||||
|
|
@ -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 }>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Generate a UUID v4
|
||||||
function generateUUID(): string {
|
function generateUUID(): string {
|
||||||
|
|
@ -7,22 +7,37 @@ function generateUUID(): string {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a user's effective permission for a board
|
* 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(
|
export async function getEffectivePermission(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
boardId: string,
|
boardId: string,
|
||||||
userId: string | null
|
userId: string | null,
|
||||||
|
accessToken?: string | null
|
||||||
): Promise<PermissionCheckResult> {
|
): Promise<PermissionCheckResult> {
|
||||||
// Check if board exists
|
// Check if board exists
|
||||||
const board = await db.prepare(
|
const board = await db.prepare(
|
||||||
'SELECT * FROM boards WHERE id = ?'
|
'SELECT * FROM boards WHERE id = ?'
|
||||||
).bind(boardId).first<Board>();
|
).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 (!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 {
|
return {
|
||||||
permission: userId ? 'edit' : 'view',
|
permission: userId ? 'edit' : 'view',
|
||||||
isOwner: false,
|
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) {
|
if (!userId) {
|
||||||
return {
|
return {
|
||||||
permission: board.default_permission as PermissionLevel,
|
permission: 'view',
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
boardExists: true
|
boardExists: true
|
||||||
};
|
};
|
||||||
|
|
@ -127,6 +143,7 @@ export async function ensureBoardExists(
|
||||||
/**
|
/**
|
||||||
* GET /boards/:boardId/permission
|
* GET /boards/:boardId/permission
|
||||||
* Get current user's permission for a board
|
* Get current user's permission for a board
|
||||||
|
* Query params: ?token=<access_token> - optional access token from share link
|
||||||
*/
|
*/
|
||||||
export async function handleGetPermission(
|
export async function handleGetPermission(
|
||||||
boardId: string,
|
boardId: string,
|
||||||
|
|
@ -136,9 +153,9 @@ export async function handleGetPermission(
|
||||||
try {
|
try {
|
||||||
const db = env.CRYPTID_DB;
|
const db = env.CRYPTID_DB;
|
||||||
if (!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({
|
return new Response(JSON.stringify({
|
||||||
permission: 'edit',
|
permission: 'view',
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
boardExists: false,
|
boardExists: false,
|
||||||
message: 'Permission system not configured'
|
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), {
|
return new Response(JSON.stringify(result), {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ function generateUUID(): string {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send email via SendGrid
|
// Send email via Resend
|
||||||
async function sendEmail(
|
async function sendEmail(
|
||||||
env: Environment,
|
env: Environment,
|
||||||
to: string,
|
to: string,
|
||||||
|
|
@ -20,24 +20,33 @@ async function sendEmail(
|
||||||
htmlContent: string
|
htmlContent: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${env.SENDGRID_API_KEY}`,
|
'Authorization': `Bearer ${env.RESEND_API_KEY}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
personalizations: [{ to: [{ email: to }] }],
|
from: env.CRYPTID_EMAIL_FROM || 'CryptID <noreply@jeffemmett.com>',
|
||||||
from: { email: env.CRYPTID_EMAIL_FROM || 'noreply@jeffemmett.com', name: 'CryptID' },
|
to: [to],
|
||||||
subject,
|
subject,
|
||||||
content: [{ type: 'text/html', value: htmlContent }],
|
html: htmlContent,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('SendGrid error:', await response.text());
|
const errorText = await response.text();
|
||||||
|
console.error('Resend error:', errorText);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await response.json() as { id?: string };
|
||||||
|
console.log('Email sent successfully, id:', result.id);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Email send error:', 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
|
* Check if a public key is linked to an account
|
||||||
* POST /auth/lookup
|
* POST /auth/lookup
|
||||||
|
|
|
||||||
|
|
@ -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_user ON board_permissions(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_board_perms_board_user ON board_permissions(board_id, 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
|
-- User Networking / Social Graph System
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export interface Environment {
|
||||||
DAILY_DOMAIN: string;
|
DAILY_DOMAIN: string;
|
||||||
// CryptID auth bindings
|
// CryptID auth bindings
|
||||||
CRYPTID_DB?: D1Database;
|
CRYPTID_DB?: D1Database;
|
||||||
SENDGRID_API_KEY?: string;
|
RESEND_API_KEY?: string;
|
||||||
CRYPTID_EMAIL_FROM?: string;
|
CRYPTID_EMAIL_FROM?: string;
|
||||||
APP_URL?: string;
|
APP_URL?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -95,6 +95,25 @@ export interface PermissionCheckResult {
|
||||||
permission: PermissionLevel;
|
permission: PermissionLevel;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
boardExists: 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,13 @@ import {
|
||||||
handleGrantPermission,
|
handleGrantPermission,
|
||||||
handleRevokePermission,
|
handleRevokePermission,
|
||||||
handleUpdateBoard,
|
handleUpdateBoard,
|
||||||
|
handleCreateAccessToken,
|
||||||
|
handleListAccessTokens,
|
||||||
|
handleRevokeAccessToken,
|
||||||
} from "./boardPermissions"
|
} from "./boardPermissions"
|
||||||
|
import {
|
||||||
|
handleSendBackupEmail,
|
||||||
|
} from "./cryptidAuth"
|
||||||
|
|
||||||
// make sure our sync durable objects are made available to cloudflare
|
// make sure our sync durable objects are made available to cloudflare
|
||||||
export { AutomergeDurableObject } from "./AutomergeDurableObject"
|
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
|
// User Networking / Social Graph API
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -877,6 +890,19 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
||||||
.patch("/boards/:boardId", (req, env) =>
|
.patch("/boards/:boardId", (req, env) =>
|
||||||
handleUpdateBoard(req.params.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) {
|
async function backupAllBoards(env: Environment) {
|
||||||
try {
|
try {
|
||||||
// List all room files from TLDRAW_BUCKET
|
// List all room files from TLDRAW_BUCKET
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ upstream_protocol = "https"
|
||||||
[dev.miniflare]
|
[dev.miniflare]
|
||||||
kv_persist = true
|
kv_persist = true
|
||||||
r2_persist = true
|
r2_persist = true
|
||||||
|
d1_persist = true
|
||||||
durable_objects_persist = true
|
durable_objects_persist = true
|
||||||
|
|
||||||
[durable_objects]
|
[durable_objects]
|
||||||
|
|
@ -37,6 +38,10 @@ binding = 'BOARD_BACKUPS_BUCKET'
|
||||||
bucket_name = 'board-backups-preview'
|
bucket_name = 'board-backups-preview'
|
||||||
preview_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]
|
[observability]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue