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": {
|
||||
"node": ">=20.0.0"
|
||||
<<<<<<< HEAD
|
||||
}
|
||||
},
|
||||
"multmux/packages/cli": {
|
||||
|
|
@ -199,8 +198,6 @@
|
|||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
=======
|
||||
>>>>>>> db7bbbf (feat: add invite/share feature with QR code, URL, NFC, and audio connect)
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/provider": {
|
||||
|
|
@ -14876,6 +14873,15 @@
|
|||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
|
|
@ -14891,14 +14897,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,53 @@ const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) =
|
|||
});
|
||||
};
|
||||
|
||||
// Detect if we're in share-panel (compact) vs toolbar (full button)
|
||||
const isCompact = className.includes('share-panel-btn');
|
||||
|
||||
if (isCompact) {
|
||||
// Icon-only version for the top-right share panel
|
||||
return (
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className={`share-board-button ${className}`}
|
||||
title="Invite others to this board"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '6px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--color-text-1)',
|
||||
opacity: 0.7,
|
||||
transition: 'opacity 0.15s, background 0.15s',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = '1';
|
||||
e.currentTarget.style.background = 'var(--color-muted-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = '0.7';
|
||||
e.currentTarget.style.background = 'none';
|
||||
}}
|
||||
>
|
||||
{/* User with plus icon (invite/add person) */}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* User outline */}
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
{/* Plus sign */}
|
||||
<line x1="19" y1="8" x2="19" y2="14" />
|
||||
<line x1="16" y1="11" x2="22" y2="11" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Full button version for other contexts (toolbar, etc.)
|
||||
return (
|
||||
<button
|
||||
onClick={handleShare}
|
||||
|
|
@ -62,7 +109,13 @@ const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) =
|
|||
e.currentTarget.style.background = "#3b82f6";
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "12px" }}>Share</span>
|
||||
{/* User with plus icon (invite/add person) */}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<line x1="19" y1="8" x2="19" y2="14" />
|
||||
<line x1="16" y1="11" x2="22" y2="11" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -75,9 +75,75 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
|
|||
}
|
||||
};
|
||||
|
||||
// Don't show the button if user is not authenticated
|
||||
if (!session.authed) {
|
||||
return null;
|
||||
// Detect if we're in share-panel (compact) vs toolbar (full button)
|
||||
const isCompact = className.includes('share-panel-btn');
|
||||
|
||||
if (isCompact) {
|
||||
// Icon-only version for the top-right share panel
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={handleStarToggle}
|
||||
disabled={isLoading}
|
||||
className={`star-board-button ${className} ${isStarred ? 'starred' : ''}`}
|
||||
title={!session.authed ? 'Log in to star boards' : isStarred ? 'Remove from starred boards' : 'Add to starred boards'}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '6px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: isStarred ? '#f59e0b' : 'var(--color-text-1)',
|
||||
opacity: isStarred ? 1 : 0.7,
|
||||
transition: 'opacity 0.15s, background 0.15s, color 0.15s',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = '1';
|
||||
e.currentTarget.style.background = 'var(--color-muted-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = isStarred ? '1' : '0.7';
|
||||
e.currentTarget.style.background = 'none';
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" className="loading-spinner">
|
||||
<path d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
|
||||
{isStarred ? (
|
||||
<path d="M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"/>
|
||||
) : (
|
||||
<path d="M2.866 14.85c-.078.444.36.791.746.593l4.39-2.256 4.389 2.256c.386.198.824-.149.746-.592l-.83-4.73 3.522-3.356c.33-.314.16-.888-.282-.95l-4.898-.696L8.465.792a.513.513 0 0 0-.927 0L5.354 5.12l-4.898.696c-.441.062-.612.636-.283.95l3.523 3.356-.83 4.73zm4.905-2.767-3.686 1.894.694-3.957a.565.565 0 0 0-.163-.505L1.71 6.745l4.052-.576a.525.525 0 0 0 .393-.288L8 2.223l1.847 3.658a.525.525 0 0 0 .393.288l4.052.575-2.906 2.77a.565.565 0 0 0-.163.506l.694 3.957-3.686-1.894a.503.503 0 0 0-.461 0z"/>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Custom popup notification */}
|
||||
{showPopup && (
|
||||
<div
|
||||
className={`star-popup star-popup-${popupType}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '40px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 100001,
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{popupMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -86,14 +152,14 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
|
|||
onClick={handleStarToggle}
|
||||
disabled={isLoading}
|
||||
className={`toolbar-btn star-board-button ${className} ${isStarred ? 'starred' : ''}`}
|
||||
title={isStarred ? 'Remove from starred boards' : 'Add to starred boards'}
|
||||
title={!session.authed ? 'Log in to star boards' : isStarred ? 'Remove from starred boards' : 'Add to starred boards'}
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="loading-spinner">
|
||||
<path d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill={isStarred ? '#f59e0b' : 'currentColor'}>
|
||||
{isStarred ? (
|
||||
<path d="M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"/>
|
||||
) : (
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,8 +1,12 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useEditor, useValue } from 'tldraw';
|
||||
import CryptID from './CryptID';
|
||||
import { GoogleDataService, type GoogleService } from '../../lib/google';
|
||||
import { GoogleExportBrowser } from '../GoogleExportBrowser';
|
||||
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from '../../lib/fathomApiKey';
|
||||
import { getMyConnections, createConnection, removeConnection, updateTrustLevel, updateEdgeMetadata } from '../../lib/networking/connectionService';
|
||||
import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from '../../lib/networking/types';
|
||||
|
||||
interface CryptIDDropdownProps {
|
||||
isDarkMode?: boolean;
|
||||
|
|
@ -13,10 +17,12 @@ interface CryptIDDropdownProps {
|
|||
* Shows logged-in user with dropdown containing account info and integrations.
|
||||
*/
|
||||
const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false }) => {
|
||||
const { session, logout } = useAuth();
|
||||
const { session, logout, updateSession } = useAuth();
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [showCryptIDModal, setShowCryptIDModal] = useState(false);
|
||||
const [showGoogleBrowser, setShowGoogleBrowser] = useState(false);
|
||||
const [showObsidianModal, setShowObsidianModal] = useState(false);
|
||||
const [obsidianVaultUrl, setObsidianVaultUrl] = useState('');
|
||||
const [googleConnected, setGoogleConnected] = useState(false);
|
||||
const [googleLoading, setGoogleLoading] = useState(false);
|
||||
const [googleCounts, setGoogleCounts] = useState<Record<GoogleService, number>>({
|
||||
|
|
@ -27,6 +33,58 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
});
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Expanded sections (only integrations and connections now)
|
||||
const [expandedSection, setExpandedSection] = useState<'none' | 'integrations' | 'connections'>('none');
|
||||
|
||||
// Fathom API key state
|
||||
const [hasFathomApiKey, setHasFathomApiKey] = useState(false);
|
||||
const [showFathomInput, setShowFathomInput] = useState(false);
|
||||
const [fathomKeyInput, setFathomKeyInput] = useState('');
|
||||
|
||||
// Connections state
|
||||
const [connections, setConnections] = useState<UserConnectionWithProfile[]>([]);
|
||||
const [connectionsLoading, setConnectionsLoading] = useState(false);
|
||||
const [editingConnectionId, setEditingConnectionId] = useState<string | null>(null);
|
||||
const [editingMetadata, setEditingMetadata] = useState<Partial<EdgeMetadata>>({});
|
||||
const [savingMetadata, setSavingMetadata] = useState(false);
|
||||
const [connectingUserId, setConnectingUserId] = useState<string | null>(null);
|
||||
|
||||
// Try to get editor (may not exist if outside tldraw context)
|
||||
let editor: any = null;
|
||||
let collaborators: any[] = [];
|
||||
try {
|
||||
editor = useEditor();
|
||||
collaborators = useValue('collaborators', () => editor?.getCollaborators() || [], [editor]) || [];
|
||||
} catch {
|
||||
// Not inside tldraw context
|
||||
}
|
||||
|
||||
// Canvas users with their connection status
|
||||
interface CanvasUser {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
connectionStatus: 'trusted' | 'connected' | 'unconnected';
|
||||
connectionId?: string;
|
||||
}
|
||||
|
||||
const canvasUsers: CanvasUser[] = useMemo(() => {
|
||||
if (!collaborators || collaborators.length === 0) return [];
|
||||
|
||||
return collaborators.map((c: any) => {
|
||||
const userId = c.userId || c.id || c.instanceId;
|
||||
const connection = connections.find(conn => conn.toUserId === userId);
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
name: c.userName || c.name || 'Anonymous',
|
||||
color: c.color || '#888',
|
||||
connectionStatus: (connection?.trustLevel || 'unconnected') as CanvasUser['connectionStatus'],
|
||||
connectionId: connection?.id,
|
||||
};
|
||||
}).filter((user) => user.name !== session.username);
|
||||
}, [collaborators, connections, session.username]);
|
||||
|
||||
// Check Google connection on mount
|
||||
useEffect(() => {
|
||||
const checkGoogleStatus = async () => {
|
||||
|
|
@ -45,17 +103,113 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
checkGoogleStatus();
|
||||
}, []);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
// Check Fathom API key
|
||||
useEffect(() => {
|
||||
if (session.authed && session.username) {
|
||||
setHasFathomApiKey(isFathomApiKeyConfigured(session.username));
|
||||
}
|
||||
}, [session.authed, session.username]);
|
||||
|
||||
// Load connections when authenticated
|
||||
useEffect(() => {
|
||||
const loadConnections = async () => {
|
||||
if (!session.authed || !session.username) return;
|
||||
setConnectionsLoading(true);
|
||||
try {
|
||||
const myConnections = await getMyConnections();
|
||||
setConnections(myConnections as UserConnectionWithProfile[]);
|
||||
} catch (error) {
|
||||
console.error('Failed to load connections:', error);
|
||||
} finally {
|
||||
setConnectionsLoading(false);
|
||||
}
|
||||
};
|
||||
loadConnections();
|
||||
}, [session.authed, session.username]);
|
||||
|
||||
// Connection handlers
|
||||
const handleConnect = async (userId: string, trustLevel: TrustLevel) => {
|
||||
if (!session.authed || !session.username) return;
|
||||
setConnectingUserId(userId);
|
||||
try {
|
||||
const newConnection = await createConnection(userId, trustLevel);
|
||||
if (newConnection) {
|
||||
setConnections(prev => [...prev, newConnection as UserConnectionWithProfile]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create connection:', error);
|
||||
} finally {
|
||||
setConnectingUserId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async (connectionId: string, userId: string) => {
|
||||
setConnectingUserId(userId);
|
||||
try {
|
||||
await removeConnection(connectionId);
|
||||
setConnections(prev => prev.filter(c => c.id !== connectionId));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove connection:', error);
|
||||
} finally {
|
||||
setConnectingUserId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeTrust = async (connectionId: string, userId: string, newLevel: TrustLevel) => {
|
||||
setConnectingUserId(userId);
|
||||
try {
|
||||
const updated = await updateTrustLevel(connectionId, newLevel);
|
||||
if (updated) {
|
||||
setConnections(prev => prev.map(c => c.id === connectionId ? updated : c));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update trust level:', error);
|
||||
} finally {
|
||||
setConnectingUserId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveMetadata = async (connectionId: string) => {
|
||||
setSavingMetadata(true);
|
||||
try {
|
||||
const updatedMetadata = await updateEdgeMetadata(connectionId, editingMetadata);
|
||||
if (updatedMetadata) {
|
||||
setConnections(prev => prev.map(c =>
|
||||
c.id === connectionId ? { ...c, metadata: updatedMetadata } : c
|
||||
));
|
||||
}
|
||||
setEditingConnectionId(null);
|
||||
setEditingMetadata({});
|
||||
} catch (error) {
|
||||
console.error('Failed to save metadata:', error);
|
||||
} finally {
|
||||
setSavingMetadata(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside or pressing ESC
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
if (showDropdown) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
// Use capture phase to intercept before tldraw
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
};
|
||||
}, [showDropdown]);
|
||||
|
||||
const handleGoogleConnect = async () => {
|
||||
|
|
@ -66,6 +220,9 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
setGoogleConnected(true);
|
||||
const counts = await service.getStoredCounts();
|
||||
setGoogleCounts(counts);
|
||||
// After successful connection, open the Google Export Browser
|
||||
setShowGoogleBrowser(true);
|
||||
setShowDropdown(false);
|
||||
} catch (error) {
|
||||
console.error('Google auth failed:', error);
|
||||
} finally {
|
||||
|
|
@ -115,10 +272,11 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="cryptid-dropdown" style={{ position: 'relative' }}>
|
||||
<div ref={dropdownRef} className="cryptid-dropdown" style={{ position: 'relative', pointerEvents: 'all' }}>
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="cryptid-trigger"
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
@ -131,6 +289,7 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
cursor: 'pointer',
|
||||
color: 'var(--color-text-1)',
|
||||
transition: 'background 0.15s',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
title={session.authed ? session.username : 'Sign in with CryptID'}
|
||||
>
|
||||
|
|
@ -179,13 +338,23 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
top: 'calc(100% + 8px)',
|
||||
right: 0,
|
||||
minWidth: '260px',
|
||||
maxHeight: 'calc(100vh - 100px)',
|
||||
background: 'var(--color-panel)',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||||
zIndex: 100000,
|
||||
overflow: 'hidden',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
onWheel={(e) => {
|
||||
// Stop wheel events from propagating to canvas when over menu
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
>
|
||||
{session.authed ? (
|
||||
<>
|
||||
|
|
@ -219,6 +388,44 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div style={{ padding: '8px 0', borderBottom: '1px solid var(--color-panel-contrast)' }}>
|
||||
<a
|
||||
href="/dashboard/"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '10px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-text)',
|
||||
textDecoration: 'none',
|
||||
transition: 'background 0.15s, transform 0.15s',
|
||||
borderRadius: '6px',
|
||||
margin: '0 8px',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--color-muted-2)';
|
||||
e.currentTarget.style.transform = 'translateX(2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.transform = 'translateX(0)';
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="#f59e0b" stroke="#f59e0b" strokeWidth="1">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
||||
</svg>
|
||||
My Saved Boards
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: 'auto', opacity: 0.5 }}>
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Integrations section */}
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<div style={{
|
||||
|
|
@ -275,31 +482,36 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
setShowGoogleBrowser(true);
|
||||
setShowDropdown(false);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 12px',
|
||||
padding: '8px 14px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
fontWeight: 600,
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
backgroundColor: '#4285F4',
|
||||
background: 'linear-gradient(135deg, #4285F4, #34A853)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(66, 133, 244, 0.3)',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
>
|
||||
Browse Data
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGoogleDisconnect}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
padding: '8px 14px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--color-text-3)',
|
||||
backgroundColor: 'var(--color-muted-2)',
|
||||
color: 'var(--color-text)',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
|
|
@ -308,18 +520,30 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
) : (
|
||||
<button
|
||||
onClick={handleGoogleConnect}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={googleLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 12px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
fontWeight: 600,
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--color-text)',
|
||||
border: 'none',
|
||||
background: 'linear-gradient(135deg, #4285F4, #34A853)',
|
||||
color: 'white',
|
||||
cursor: googleLoading ? 'wait' : 'pointer',
|
||||
opacity: googleLoading ? 0.7 : 1,
|
||||
transition: 'transform 0.15s, box-shadow 0.15s',
|
||||
boxShadow: '0 2px 8px rgba(66, 133, 244, 0.3)',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(66, 133, 244, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(66, 133, 244, 0.3)';
|
||||
}}
|
||||
>
|
||||
{googleLoading ? 'Connecting...' : 'Connect Google'}
|
||||
|
|
@ -327,6 +551,250 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Obsidian Vault */}
|
||||
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-panel-contrast)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
||||
<div style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
background: '#7c3aed',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
📁
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text)' }}>
|
||||
Obsidian Vault
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>
|
||||
{session.obsidianVaultName || 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
{session.obsidianVaultName && (
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#22c55e',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowObsidianModal(true);
|
||||
setShowDropdown(false);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: session.obsidianVaultName
|
||||
? 'var(--color-muted-2)'
|
||||
: 'linear-gradient(135deg, #7c3aed 0%, #a855f7 100%)',
|
||||
color: session.obsidianVaultName ? 'var(--color-text)' : 'white',
|
||||
cursor: 'pointer',
|
||||
boxShadow: session.obsidianVaultName ? 'none' : '0 2px 8px rgba(124, 58, 237, 0.3)',
|
||||
pointerEvents: 'all',
|
||||
transition: 'transform 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!session.obsidianVaultName) {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(124, 58, 237, 0.4)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = session.obsidianVaultName ? 'none' : '0 2px 8px rgba(124, 58, 237, 0.3)';
|
||||
}}
|
||||
>
|
||||
{session.obsidianVaultName ? 'Change Vault' : 'Connect Vault'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Fathom Meetings */}
|
||||
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-panel-contrast)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
||||
<div style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
background: '#ef4444',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
🎥
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text)' }}>
|
||||
Fathom Meetings
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>
|
||||
{hasFathomApiKey ? 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
{hasFathomApiKey && (
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#22c55e',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
{showFathomInput ? (
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
value={fathomKeyInput}
|
||||
onChange={(e) => setFathomKeyInput(e.target.value)}
|
||||
placeholder="Enter Fathom API key..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
fontSize: '11px',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '6px',
|
||||
background: 'var(--color-panel)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && fathomKeyInput.trim()) {
|
||||
saveFathomApiKey(fathomKeyInput.trim(), session.username);
|
||||
setHasFathomApiKey(true);
|
||||
setShowFathomInput(false);
|
||||
setFathomKeyInput('');
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowFathomInput(false);
|
||||
setFathomKeyInput('');
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (fathomKeyInput.trim()) {
|
||||
saveFathomApiKey(fathomKeyInput.trim(), session.username);
|
||||
setHasFathomApiKey(true);
|
||||
setShowFathomInput(false);
|
||||
setFathomKeyInput('');
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 10px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
backgroundColor: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFathomInput(false);
|
||||
setFathomKeyInput('');
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 10px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
backgroundColor: 'var(--color-muted-2)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--color-text)',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFathomInput(true);
|
||||
const currentKey = getFathomApiKey(session.username);
|
||||
if (currentKey) setFathomKeyInput(currentKey);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: hasFathomApiKey
|
||||
? 'var(--color-muted-2)'
|
||||
: 'linear-gradient(135deg, #ef4444 0%, #f97316 100%)',
|
||||
color: hasFathomApiKey ? 'var(--color-text)' : 'white',
|
||||
cursor: 'pointer',
|
||||
boxShadow: hasFathomApiKey ? 'none' : '0 2px 8px rgba(239, 68, 68, 0.3)',
|
||||
pointerEvents: 'all',
|
||||
transition: 'transform 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!hasFathomApiKey) {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(239, 68, 68, 0.4)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = hasFathomApiKey ? 'none' : '0 2px 8px rgba(239, 68, 68, 0.3)';
|
||||
}}
|
||||
>
|
||||
{hasFathomApiKey ? 'Change Key' : 'Add API Key'}
|
||||
</button>
|
||||
{hasFathomApiKey && (
|
||||
<button
|
||||
onClick={() => {
|
||||
removeFathomApiKey(session.username);
|
||||
setHasFathomApiKey(false);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
borderRadius: '6px',
|
||||
backgroundColor: '#fee2e2',
|
||||
color: '#dc2626',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sign out */}
|
||||
|
|
@ -410,6 +878,240 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Obsidian Vault Connection Modal */}
|
||||
{showObsidianModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 100001,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowObsidianModal(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--color-panel)',
|
||||
borderRadius: '16px',
|
||||
padding: '24px',
|
||||
width: '400px',
|
||||
maxWidth: '90vw',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '20px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '10px',
|
||||
background: 'linear-gradient(135deg, #7c3aed 0%, #a855f7 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px',
|
||||
}}>
|
||||
📁
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: '18px', fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Connect Obsidian Vault
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-3)' }}>
|
||||
Import your notes to the canvas
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session.obsidianVaultName && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
backgroundColor: 'rgba(124, 58, 237, 0.1)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
}}>
|
||||
<span style={{ color: '#22c55e', fontSize: '16px' }}>✓</span>
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text)' }}>
|
||||
Currently connected
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)' }}>
|
||||
{session.obsidianVaultName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{/* Option 1: Select Local Folder */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
// Use File System Access API
|
||||
const dirHandle = await (window as any).showDirectoryPicker({
|
||||
mode: 'read',
|
||||
});
|
||||
const vaultName = dirHandle.name;
|
||||
updateSession({
|
||||
obsidianVaultPath: 'folder-selected',
|
||||
obsidianVaultName: vaultName,
|
||||
});
|
||||
setShowObsidianModal(false);
|
||||
// Dispatch event to open browser on canvas
|
||||
window.dispatchEvent(new CustomEvent('open-obsidian-browser'));
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Failed to select folder:', err);
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
borderRadius: '10px',
|
||||
border: 'none',
|
||||
background: 'linear-gradient(135deg, #7c3aed 0%, #a855f7 100%)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
boxShadow: '0 4px 12px rgba(124, 58, 237, 0.3)',
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
Select Local Folder
|
||||
</button>
|
||||
|
||||
{/* Option 2: Enter URL */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="Or enter Quartz/Obsidian Publish URL..."
|
||||
value={obsidianVaultUrl}
|
||||
onChange={(e) => setObsidianVaultUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && obsidianVaultUrl.trim()) {
|
||||
updateSession({
|
||||
obsidianVaultPath: obsidianVaultUrl.trim(),
|
||||
obsidianVaultName: new URL(obsidianVaultUrl.trim()).hostname,
|
||||
});
|
||||
setShowObsidianModal(false);
|
||||
setObsidianVaultUrl('');
|
||||
window.dispatchEvent(new CustomEvent('open-obsidian-browser'));
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 14px',
|
||||
fontSize: '13px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
background: 'var(--color-panel)',
|
||||
color: 'var(--color-text)',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
{obsidianVaultUrl && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (obsidianVaultUrl.trim()) {
|
||||
try {
|
||||
updateSession({
|
||||
obsidianVaultPath: obsidianVaultUrl.trim(),
|
||||
obsidianVaultName: new URL(obsidianVaultUrl.trim()).hostname,
|
||||
});
|
||||
setShowObsidianModal(false);
|
||||
setObsidianVaultUrl('');
|
||||
window.dispatchEvent(new CustomEvent('open-obsidian-browser'));
|
||||
} catch (err) {
|
||||
console.error('Invalid URL:', err);
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '8px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: '#7c3aed',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{session.obsidianVaultName && (
|
||||
<button
|
||||
onClick={() => {
|
||||
updateSession({
|
||||
obsidianVaultPath: undefined,
|
||||
obsidianVaultName: undefined,
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
backgroundColor: '#fee2e2',
|
||||
color: '#dc2626',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Disconnect Vault
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowObsidianModal(false)}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: '16px',
|
||||
padding: '10px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
backgroundColor: 'var(--color-muted-2)',
|
||||
color: 'var(--color-text)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
myConnections: string[];
|
||||
currentUserId?: string;
|
||||
onConnect: (userId: string) => Promise<void>;
|
||||
onConnect: (userId: string, trustLevel?: TrustLevel) => Promise<void>;
|
||||
onDisconnect?: (connectionId: string) => Promise<void>;
|
||||
onNodeClick?: (node: GraphNode) => void;
|
||||
onEdgeClick?: (edge: GraphEdge) => void;
|
||||
|
|
@ -38,6 +38,7 @@ interface NetworkGraphMinimapProps {
|
|||
height?: number;
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
interface SimulationNode extends d3.SimulationNodeDatum, GraphNode {}
|
||||
|
|
@ -47,10 +48,10 @@ interface SimulationLink extends d3.SimulationLinkDatum<SimulationNode> {
|
|||
}
|
||||
|
||||
// =============================================================================
|
||||
// Styles
|
||||
// Styles - Theme-aware functions
|
||||
// =============================================================================
|
||||
|
||||
const styles = {
|
||||
const getStyles = (isDarkMode: boolean) => ({
|
||||
container: {
|
||||
position: 'fixed' as const,
|
||||
bottom: '60px',
|
||||
|
|
@ -62,12 +63,12 @@ const styles = {
|
|||
gap: '8px',
|
||||
},
|
||||
panel: {
|
||||
backgroundColor: 'rgba(20, 20, 25, 0.95)',
|
||||
backgroundColor: isDarkMode ? 'rgba(20, 20, 25, 0.95)' : 'rgba(255, 255, 255, 0.98)',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.4)',
|
||||
boxShadow: isDarkMode ? '0 4px 20px rgba(0, 0, 0, 0.4)' : '0 4px 20px rgba(0, 0, 0, 0.15)',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s ease',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
border: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
panelCollapsed: {
|
||||
width: '48px',
|
||||
|
|
@ -82,13 +83,13 @@ const styles = {
|
|||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
||||
borderBottom: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||
backgroundColor: isDarkMode ? 'rgba(255, 255, 255, 0.03)' : 'rgba(0, 0, 0, 0.02)',
|
||||
},
|
||||
title: {
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#e0e0e0',
|
||||
color: isDarkMode ? '#e0e0e0' : '#374151',
|
||||
margin: 0,
|
||||
},
|
||||
headerButtons: {
|
||||
|
|
@ -106,16 +107,17 @@ const styles = {
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '14px',
|
||||
color: '#a0a0a0',
|
||||
color: isDarkMode ? '#a0a0a0' : '#6b7280',
|
||||
transition: 'background-color 0.15s, color 0.15s',
|
||||
},
|
||||
canvas: {
|
||||
display: 'block',
|
||||
backgroundColor: isDarkMode ? 'transparent' : 'rgba(249, 250, 251, 0.5)',
|
||||
},
|
||||
tooltip: {
|
||||
position: 'absolute' as const,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
color: '#fff',
|
||||
backgroundColor: isDarkMode ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.95)',
|
||||
color: isDarkMode ? '#fff' : '#1f2937',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
|
|
@ -124,6 +126,8 @@ const styles = {
|
|||
zIndex: 1001,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
marginTop: '-8px',
|
||||
boxShadow: isDarkMode ? 'none' : '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
border: isDarkMode ? 'none' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
collapsedIcon: {
|
||||
fontSize: '20px',
|
||||
|
|
@ -132,10 +136,10 @@ const styles = {
|
|||
display: 'flex',
|
||||
gap: '12px',
|
||||
padding: '6px 12px',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderTop: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.08)',
|
||||
fontSize: '11px',
|
||||
color: '#888',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||
color: isDarkMode ? '#888' : '#6b7280',
|
||||
backgroundColor: isDarkMode ? 'rgba(0, 0, 0, 0.2)' : 'rgba(0, 0, 0, 0.02)',
|
||||
},
|
||||
stat: {
|
||||
display: 'flex',
|
||||
|
|
@ -147,7 +151,7 @@ const styles = {
|
|||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
|
|
@ -167,6 +171,7 @@ export function NetworkGraphMinimap({
|
|||
height = 180,
|
||||
isCollapsed = false,
|
||||
onToggleCollapse,
|
||||
isDarkMode = false,
|
||||
}: NetworkGraphMinimapProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
|
||||
|
|
@ -175,6 +180,9 @@ export function NetworkGraphMinimap({
|
|||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const simulationRef = useRef<d3.Simulation<SimulationNode, SimulationLink> | null>(null);
|
||||
|
||||
// Get theme-aware styles
|
||||
const styles = React.useMemo(() => getStyles(isDarkMode), [isDarkMode]);
|
||||
|
||||
// Count stats
|
||||
const inRoomCount = nodes.filter(n => n.isInRoom).length;
|
||||
const anonymousCount = nodes.filter(n => n.isAnonymous).length;
|
||||
|
|
@ -202,14 +210,18 @@ export function NetworkGraphMinimap({
|
|||
isMutual: e.isMutual,
|
||||
}));
|
||||
|
||||
// Create the simulation
|
||||
// Create the simulation with faster decay for stabilization
|
||||
const simulation = d3.forceSimulation<SimulationNode>(simNodes)
|
||||
.force('link', d3.forceLink<SimulationNode, SimulationLink>(simLinks)
|
||||
.id(d => d.id)
|
||||
.distance(40))
|
||||
.force('charge', d3.forceManyBody().strength(-80))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide().radius(12));
|
||||
.force('collision', d3.forceCollide().radius(12))
|
||||
// Speed up stabilization: higher decay = faster settling
|
||||
.alphaDecay(0.05)
|
||||
// Lower alpha min threshold for stopping
|
||||
.alphaMin(0.01);
|
||||
|
||||
simulationRef.current = simulation;
|
||||
|
||||
|
|
@ -404,6 +416,12 @@ export function NetworkGraphMinimap({
|
|||
.attr('cy', d => Math.max(8, Math.min(height - 8, d.y!)));
|
||||
});
|
||||
|
||||
// Stop simulation when it stabilizes (alpha reaches alphaMin)
|
||||
simulation.on('end', () => {
|
||||
// Simulation has stabilized, nodes will stay in place unless dragged
|
||||
simulation.stop();
|
||||
});
|
||||
|
||||
return () => {
|
||||
simulation.stop();
|
||||
};
|
||||
|
|
@ -573,7 +591,9 @@ export function NetworkGraphMinimap({
|
|||
onClick={async () => {
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
await onConnect(selectedNode.node.id);
|
||||
// Use username for API call (CryptID username), not tldraw session id
|
||||
const userId = selectedNode.node.username || selectedNode.node.id;
|
||||
await onConnect(userId, 'connected');
|
||||
} catch (err) {
|
||||
console.error('Failed to connect:', err);
|
||||
}
|
||||
|
|
@ -597,9 +617,10 @@ export function NetworkGraphMinimap({
|
|||
onClick={async () => {
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
// Connect with trusted level
|
||||
await onConnect(selectedNode.node.id);
|
||||
// Then upgrade - would need separate call
|
||||
// Use username for API call (CryptID username), not tldraw session id
|
||||
// Connect with trusted level directly
|
||||
const userId = selectedNode.node.username || selectedNode.node.id;
|
||||
await onConnect(userId, 'trusted');
|
||||
} catch (err) {
|
||||
console.error('Failed to connect:', err);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,24 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
|
|||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [selectedEdge, setSelectedEdge] = useState<GraphEdge | null>(null);
|
||||
|
||||
// Detect dark mode
|
||||
const [isDarkMode, setIsDarkMode] = useState(
|
||||
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
// Listen for theme changes
|
||||
React.useEffect(() => {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
setIsDarkMode(document.documentElement.classList.contains('dark'));
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Get collaborators from tldraw
|
||||
const collaborators = useValue(
|
||||
'collaborators',
|
||||
|
|
@ -78,9 +96,9 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
|
|||
useCache: true,
|
||||
});
|
||||
|
||||
// Handle connect with default trust level
|
||||
const handleConnect = useCallback(async (userId: string) => {
|
||||
await connect(userId);
|
||||
// Handle connect with optional trust level
|
||||
const handleConnect = useCallback(async (userId: string, trustLevel: TrustLevel = 'connected') => {
|
||||
await connect(userId, trustLevel);
|
||||
}, [connect]);
|
||||
|
||||
// Handle disconnect
|
||||
|
|
@ -142,6 +160,7 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
|
|||
onExpandClick={handleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
type NetworkGraph,
|
||||
type GraphNode,
|
||||
type GraphEdge,
|
||||
type TrustLevel,
|
||||
} from '../../lib/networking';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -53,8 +54,8 @@ export interface UseNetworkGraphOptions {
|
|||
export interface UseNetworkGraphReturn extends NetworkGraphState {
|
||||
// Refresh the graph from the server
|
||||
refresh: () => Promise<void>;
|
||||
// Connect to a user
|
||||
connect: (userId: string) => Promise<void>;
|
||||
// Connect to a user with optional trust level
|
||||
connect: (userId: string, trustLevel?: TrustLevel) => Promise<void>;
|
||||
// Disconnect from a user
|
||||
disconnect: (connectionId: string) => Promise<void>;
|
||||
// Check if connected to a user
|
||||
|
|
@ -267,9 +268,9 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
|
|||
}, [participantIds, participantColorMap]);
|
||||
|
||||
// Connect to a user
|
||||
const connect = useCallback(async (userId: string) => {
|
||||
const connect = useCallback(async (userId: string, trustLevel: TrustLevel = 'connected') => {
|
||||
try {
|
||||
await createConnection(userId);
|
||||
await createConnection(userId, trustLevel);
|
||||
// Refresh the graph to get updated state
|
||||
await fetchGraph(true);
|
||||
clearGraphCache();
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ interface AuthContextType {
|
|||
canEdit: () => boolean;
|
||||
/** Check if user is admin for the current board */
|
||||
isAdmin: () => boolean;
|
||||
/** Current access token from URL (if any) */
|
||||
accessToken: string | null;
|
||||
/** Set access token (from URL parameter) */
|
||||
setAccessToken: (token: string | null) => void;
|
||||
}
|
||||
|
||||
const initialSession: Session = {
|
||||
|
|
@ -35,6 +39,22 @@ export const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
|||
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [session, setSessionState] = useState<Session>(initialSession);
|
||||
const [accessToken, setAccessTokenState] = useState<string | null>(null);
|
||||
|
||||
// Extract access token from URL on mount
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const token = urlParams.get('token');
|
||||
if (token) {
|
||||
console.log('🔑 Access token found in URL');
|
||||
setAccessTokenState(token);
|
||||
// Optionally remove from URL to clean it up (but keep the token in state)
|
||||
// This prevents the token from being shared if someone copies the URL
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete('token');
|
||||
window.history.replaceState({}, '', newUrl.toString());
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update session with partial data
|
||||
const setSession = useCallback((updatedSession: Partial<Session>) => {
|
||||
|
|
@ -175,12 +195,26 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
}
|
||||
}, [clearSession]);
|
||||
|
||||
// Setter for access token
|
||||
const setAccessToken = useCallback((token: string | null) => {
|
||||
setAccessTokenState(token);
|
||||
// Clear cached permissions when token changes (they may be different)
|
||||
if (token) {
|
||||
setSessionState(prev => ({
|
||||
...prev,
|
||||
boardPermissions: {},
|
||||
currentBoardPermission: undefined,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fetch and cache the user's permission level for a specific board
|
||||
* Includes access token if available (from share link)
|
||||
*/
|
||||
const fetchBoardPermission = useCallback(async (boardId: string): Promise<PermissionLevel> => {
|
||||
// Check cache first
|
||||
if (session.boardPermissions?.[boardId]) {
|
||||
// Check cache first (but only if no access token - token changes permissions)
|
||||
if (!accessToken && session.boardPermissions?.[boardId]) {
|
||||
return session.boardPermissions[boardId];
|
||||
}
|
||||
|
||||
|
|
@ -197,23 +231,35 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${WORKER_URL}/boards/${boardId}/permission`, {
|
||||
// Build URL with optional access token
|
||||
let url = `${WORKER_URL}/boards/${boardId}/permission`;
|
||||
if (accessToken) {
|
||||
url += `?token=${encodeURIComponent(accessToken)}`;
|
||||
console.log('🔑 Including access token in permission check');
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch board permission:', response.status);
|
||||
// Default to 'view' for unauthenticated, 'edit' for authenticated
|
||||
return session.authed ? 'edit' : 'view';
|
||||
// Default to 'view' for unauthenticated (secure by default)
|
||||
return 'view';
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
permission: PermissionLevel;
|
||||
isOwner: boolean;
|
||||
boardExists: boolean;
|
||||
grantedByToken?: boolean;
|
||||
};
|
||||
|
||||
if (data.grantedByToken) {
|
||||
console.log('🔓 Permission granted via access token:', data.permission);
|
||||
}
|
||||
|
||||
// Cache the permission
|
||||
setSessionState(prev => ({
|
||||
...prev,
|
||||
|
|
@ -227,10 +273,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
return data.permission;
|
||||
} catch (error) {
|
||||
console.error('Error fetching board permission:', error);
|
||||
// Default to 'view' for unauthenticated, 'edit' for authenticated
|
||||
return session.authed ? 'edit' : 'view';
|
||||
// Default to 'view' (secure by default)
|
||||
return 'view';
|
||||
}
|
||||
}, [session.authed, session.username, session.boardPermissions]);
|
||||
}, [session.authed, session.username, session.boardPermissions, accessToken]);
|
||||
|
||||
/**
|
||||
* Check if user can edit the current board
|
||||
|
|
@ -278,7 +324,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
fetchBoardPermission,
|
||||
canEdit,
|
||||
isAdmin,
|
||||
}), [session, setSession, clearSession, initialize, login, register, logout, fetchBoardPermission, canEdit, isAdmin]);
|
||||
accessToken,
|
||||
setAccessToken,
|
||||
}), [session, setSession, clearSession, initialize, login, register, logout, fetchBoardPermission, canEdit, isAdmin, accessToken, setAccessToken]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { Editor } from 'tldraw'
|
||||
import { Editor, TLShapeId } from 'tldraw'
|
||||
|
||||
interface OriginalDimensions {
|
||||
x: number
|
||||
|
|
@ -12,7 +12,7 @@ interface UseMaximizeOptions {
|
|||
/** Editor instance */
|
||||
editor: Editor
|
||||
/** Shape ID to maximize */
|
||||
shapeId: string
|
||||
shapeId: TLShapeId
|
||||
/** Current width of the shape */
|
||||
currentW: number
|
||||
/** Current height of the shape */
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export interface Session {
|
|||
authed: boolean;
|
||||
loading: boolean;
|
||||
backupCreated: boolean | null;
|
||||
email?: string; // Email for account backup
|
||||
obsidianVaultPath?: string;
|
||||
obsidianVaultName?: string;
|
||||
error?: string;
|
||||
|
|
|
|||
|
|
@ -30,13 +30,41 @@ const API_BASE = '/api/networking';
|
|||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get the current user's CryptID username from localStorage session
|
||||
*/
|
||||
function getCurrentUserId(): string | null {
|
||||
try {
|
||||
const sessionStr = localStorage.getItem('cryptid_session');
|
||||
if (sessionStr) {
|
||||
const session = JSON.parse(sessionStr);
|
||||
if (session.authed && session.username) {
|
||||
return session.username;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
// Get the current user ID for authentication
|
||||
const userId = getCurrentUserId();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options?.headers as Record<string, string> || {}),
|
||||
};
|
||||
|
||||
// Add user ID header for authentication
|
||||
if (userId) {
|
||||
headers['X-User-Id'] = userId;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -948,28 +948,33 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Section */}
|
||||
{/* Input Section - Mobile Optimized */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: shape.props.w < 350 ? "column" : "row",
|
||||
gap: 8,
|
||||
flexShrink: 0,
|
||||
padding: "4px 0",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
<textarea
|
||||
style={{
|
||||
flex: 1,
|
||||
height: "36px",
|
||||
minHeight: "48px",
|
||||
height: shape.props.w < 350 ? "60px" : "48px",
|
||||
backgroundColor: "#fff",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "6px",
|
||||
fontSize: 13,
|
||||
padding: "0 10px",
|
||||
borderRadius: "8px",
|
||||
fontSize: 14,
|
||||
padding: "12px",
|
||||
touchAction: "manipulation",
|
||||
minHeight: "44px",
|
||||
resize: "none",
|
||||
fontFamily: "inherit",
|
||||
lineHeight: "1.4",
|
||||
WebkitAppearance: "none",
|
||||
}}
|
||||
type="text"
|
||||
placeholder="Enter image prompt..."
|
||||
placeholder="Describe the image you want to generate..."
|
||||
value={shape.props.prompt}
|
||||
onChange={(e) => {
|
||||
editor.updateShape<IImageGen>({
|
||||
|
|
@ -1000,20 +1005,26 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
/>
|
||||
<button
|
||||
style={{
|
||||
height: "36px",
|
||||
padding: "0 16px",
|
||||
height: shape.props.w < 350 ? "48px" : "48px",
|
||||
padding: "0 20px",
|
||||
pointerEvents: "all",
|
||||
cursor: shape.props.prompt.trim() && !shape.props.isLoading ? "pointer" : "not-allowed",
|
||||
backgroundColor: shape.props.prompt.trim() && !shape.props.isLoading ? ImageGenShape.PRIMARY_COLOR : "#ccc",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
fontWeight: "500",
|
||||
fontSize: "13px",
|
||||
borderRadius: "8px",
|
||||
fontWeight: "600",
|
||||
fontSize: "14px",
|
||||
opacity: shape.props.prompt.trim() && !shape.props.isLoading ? 1 : 0.6,
|
||||
touchAction: "manipulation",
|
||||
minWidth: "44px",
|
||||
minHeight: "44px",
|
||||
minWidth: shape.props.w < 350 ? "100%" : "100px",
|
||||
minHeight: "48px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "6px",
|
||||
transition: "background-color 0.15s, transform 0.1s",
|
||||
WebkitTapHighlightColor: "transparent",
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation()
|
||||
|
|
@ -1024,10 +1035,13 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation()
|
||||
// Visual feedback on touch
|
||||
e.currentTarget.style.transform = "scale(0.98)"
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
e.currentTarget.style.transform = "scale(1)"
|
||||
if (shape.props.prompt.trim() && !shape.props.isLoading) {
|
||||
handleGenerate()
|
||||
}
|
||||
|
|
@ -1041,6 +1055,7 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
}}
|
||||
disabled={shape.props.isLoading || !shape.props.prompt.trim()}
|
||||
>
|
||||
<span style={{ fontSize: "16px" }}>✨</span>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -105,6 +105,77 @@ const DEFAULT_VIEWPORT: MapViewport = {
|
|||
|
||||
const OSRM_BASE_URL = 'https://routing.jeffemmett.com';
|
||||
|
||||
// =============================================================================
|
||||
// Geo Calculation Helpers
|
||||
// =============================================================================
|
||||
|
||||
// Haversine distance calculation (returns meters)
|
||||
function calculateDistance(coords: Coordinate[]): number {
|
||||
if (coords.length < 2) return 0;
|
||||
|
||||
const R = 6371000; // Earth's radius in meters
|
||||
let total = 0;
|
||||
|
||||
for (let i = 0; i < coords.length - 1; i++) {
|
||||
const lat1 = coords[i].lat * Math.PI / 180;
|
||||
const lat2 = coords[i + 1].lat * Math.PI / 180;
|
||||
const dLat = (coords[i + 1].lat - coords[i].lat) * Math.PI / 180;
|
||||
const dLng = (coords[i + 1].lng - coords[i].lng) * Math.PI / 180;
|
||||
|
||||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1) * Math.cos(lat2) *
|
||||
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
total += R * c;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
// Shoelace formula for polygon area (returns square meters)
|
||||
function calculateArea(coords: Coordinate[]): number {
|
||||
if (coords.length < 3) return 0;
|
||||
|
||||
// Convert to projected coordinates (approximate for small areas)
|
||||
const centerLat = coords.reduce((sum, c) => sum + c.lat, 0) / coords.length;
|
||||
const metersPerDegreeLat = 111320;
|
||||
const metersPerDegreeLng = 111320 * Math.cos(centerLat * Math.PI / 180);
|
||||
|
||||
const projected = coords.map(c => ({
|
||||
x: c.lng * metersPerDegreeLng,
|
||||
y: c.lat * metersPerDegreeLat,
|
||||
}));
|
||||
|
||||
// Shoelace formula
|
||||
let area = 0;
|
||||
for (let i = 0; i < projected.length; i++) {
|
||||
const j = (i + 1) % projected.length;
|
||||
area += projected[i].x * projected[j].y;
|
||||
area -= projected[j].x * projected[i].y;
|
||||
}
|
||||
|
||||
return Math.abs(area / 2);
|
||||
}
|
||||
|
||||
// Format distance for display
|
||||
function formatDistance(meters: number): string {
|
||||
if (meters < 1000) {
|
||||
return `${Math.round(meters)} m`;
|
||||
}
|
||||
return `${(meters / 1000).toFixed(2)} km`;
|
||||
}
|
||||
|
||||
// Format area for display
|
||||
function formatArea(sqMeters: number): string {
|
||||
if (sqMeters < 10000) {
|
||||
return `${Math.round(sqMeters)} m²`;
|
||||
} else if (sqMeters < 1000000) {
|
||||
return `${(sqMeters / 10000).toFixed(2)} ha`;
|
||||
}
|
||||
return `${(sqMeters / 1000000).toFixed(2)} km²`;
|
||||
}
|
||||
|
||||
// Mapus color palette
|
||||
const COLORS = [
|
||||
'#E15F59', // Red
|
||||
|
|
@ -331,10 +402,24 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
|
|||
const activeToolRef = useRef(activeTool); // Ref to track current tool in event handlers
|
||||
const [selectedColor, setSelectedColor] = useState(COLORS[4]);
|
||||
|
||||
// Keep ref in sync with state
|
||||
// Drawing state for lines and areas
|
||||
const [drawingPoints, setDrawingPoints] = useState<Coordinate[]>([]);
|
||||
const drawingPointsRef = useRef<Coordinate[]>([]);
|
||||
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => {
|
||||
activeToolRef.current = activeTool;
|
||||
// Clear drawing points when switching tools
|
||||
if (activeTool !== 'line' && activeTool !== 'area') {
|
||||
setDrawingPoints([]);
|
||||
drawingPointsRef.current = [];
|
||||
}
|
||||
}, [activeTool]);
|
||||
|
||||
useEffect(() => {
|
||||
drawingPointsRef.current = drawingPoints;
|
||||
}, [drawingPoints]);
|
||||
|
||||
const [showColorPicker, setShowColorPicker] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||
|
|
@ -418,24 +503,75 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
|
|||
if (!isMountedRef.current) return;
|
||||
const coord = { lat: e.lngLat.lat, lng: e.lngLat.lng };
|
||||
const currentTool = activeToolRef.current;
|
||||
const currentDrawingPoints = drawingPointsRef.current;
|
||||
|
||||
console.log('Map click with tool:', currentTool, 'at', coord);
|
||||
console.log('Map click with tool:', currentTool, 'at', coord, 'points:', currentDrawingPoints.length);
|
||||
|
||||
if (currentTool === 'marker') {
|
||||
addAnnotation('marker', [coord]);
|
||||
} else if (currentTool === 'line') {
|
||||
// Add point to line drawing
|
||||
const newPoints = [...currentDrawingPoints, coord];
|
||||
setDrawingPoints(newPoints);
|
||||
drawingPointsRef.current = newPoints;
|
||||
} else if (currentTool === 'area') {
|
||||
// Add point to area drawing
|
||||
const newPoints = [...currentDrawingPoints, coord];
|
||||
setDrawingPoints(newPoints);
|
||||
drawingPointsRef.current = newPoints;
|
||||
} else if (currentTool === 'eraser') {
|
||||
// Find and remove annotation at click location
|
||||
// Check if clicked near any annotation
|
||||
const clickThreshold = 0.0005; // ~50m at equator
|
||||
const annotationToRemove = shape.props.annotations.find((ann: Annotation) => {
|
||||
if (ann.type === 'marker') {
|
||||
const annCoord = ann.coordinates[0];
|
||||
return Math.abs(annCoord.lat - coord.lat) < clickThreshold &&
|
||||
Math.abs(annCoord.lng - coord.lng) < clickThreshold;
|
||||
} else {
|
||||
// For lines/areas, check if click is near any point
|
||||
return ann.coordinates.some((c: Coordinate) =>
|
||||
Math.abs(c.lat - coord.lat) < clickThreshold &&
|
||||
Math.abs(c.lng - coord.lng) < clickThreshold
|
||||
);
|
||||
}
|
||||
});
|
||||
if (annotationToRemove) {
|
||||
removeAnnotation(annotationToRemove.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle double-click to finish line/area drawing
|
||||
const handleDblClick = (_e: maplibregl.MapMouseEvent) => {
|
||||
if (!isMountedRef.current) return;
|
||||
const currentTool = activeToolRef.current;
|
||||
const currentDrawingPoints = drawingPointsRef.current;
|
||||
|
||||
console.log('Map double-click with tool:', currentTool, 'points:', currentDrawingPoints.length);
|
||||
|
||||
if (currentTool === 'line' && currentDrawingPoints.length >= 2) {
|
||||
addAnnotation('line', currentDrawingPoints);
|
||||
setDrawingPoints([]);
|
||||
drawingPointsRef.current = [];
|
||||
} else if (currentTool === 'area' && currentDrawingPoints.length >= 3) {
|
||||
addAnnotation('area', currentDrawingPoints);
|
||||
setDrawingPoints([]);
|
||||
drawingPointsRef.current = [];
|
||||
}
|
||||
// TODO: Implement line and area drawing
|
||||
};
|
||||
|
||||
map.on('load', handleLoad);
|
||||
map.on('moveend', handleMoveEnd);
|
||||
map.on('click', handleClick);
|
||||
map.on('dblclick', handleDblClick);
|
||||
|
||||
return () => {
|
||||
// Remove event listeners before destroying map
|
||||
map.off('load', handleLoad);
|
||||
map.off('moveend', handleMoveEnd);
|
||||
map.off('click', handleClick);
|
||||
map.off('dblclick', handleDblClick);
|
||||
|
||||
// Clear all markers
|
||||
markersRef.current.forEach((marker) => {
|
||||
|
|
@ -576,8 +712,255 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
|
|||
markersRef.current.delete(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Render lines and areas
|
||||
shape.props.annotations.forEach((ann: Annotation) => {
|
||||
if (!isMountedRef.current || !mapRef.current) return;
|
||||
if (!ann.visible) {
|
||||
// Remove layer/source if hidden
|
||||
try {
|
||||
if (map.getLayer(`ann-layer-${ann.id}`)) map.removeLayer(`ann-layer-${ann.id}`);
|
||||
if (ann.type === 'area' && map.getLayer(`ann-fill-${ann.id}`)) map.removeLayer(`ann-fill-${ann.id}`);
|
||||
if (map.getSource(`ann-source-${ann.id}`)) map.removeSource(`ann-source-${ann.id}`);
|
||||
} catch (err) { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
if (ann.type === 'line' && ann.coordinates.length >= 2) {
|
||||
const coords = ann.coordinates.map((c: Coordinate) => [c.lng, c.lat]);
|
||||
const sourceId = `ann-source-${ann.id}`;
|
||||
const layerId = `ann-layer-${ann.id}`;
|
||||
|
||||
try {
|
||||
if (map.getSource(sourceId)) {
|
||||
(map.getSource(sourceId) as maplibregl.GeoJSONSource).setData({
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'LineString', coordinates: coords },
|
||||
});
|
||||
} else {
|
||||
map.addSource(sourceId, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'LineString', coordinates: coords },
|
||||
},
|
||||
});
|
||||
map.addLayer({
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: sourceId,
|
||||
paint: {
|
||||
'line-color': ann.color,
|
||||
'line-width': 4,
|
||||
'line-opacity': 0.8,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Error rendering line:', err);
|
||||
}
|
||||
} else if (ann.type === 'area' && ann.coordinates.length >= 3) {
|
||||
const coords = ann.coordinates.map((c: Coordinate) => [c.lng, c.lat]);
|
||||
// Close the polygon
|
||||
const closedCoords = [...coords, coords[0]];
|
||||
const sourceId = `ann-source-${ann.id}`;
|
||||
const fillLayerId = `ann-fill-${ann.id}`;
|
||||
const lineLayerId = `ann-layer-${ann.id}`;
|
||||
|
||||
try {
|
||||
if (map.getSource(sourceId)) {
|
||||
(map.getSource(sourceId) as maplibregl.GeoJSONSource).setData({
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'Polygon', coordinates: [closedCoords] },
|
||||
});
|
||||
} else {
|
||||
map.addSource(sourceId, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'Polygon', coordinates: [closedCoords] },
|
||||
},
|
||||
});
|
||||
map.addLayer({
|
||||
id: fillLayerId,
|
||||
type: 'fill',
|
||||
source: sourceId,
|
||||
paint: {
|
||||
'fill-color': ann.color,
|
||||
'fill-opacity': 0.3,
|
||||
},
|
||||
});
|
||||
map.addLayer({
|
||||
id: lineLayerId,
|
||||
type: 'line',
|
||||
source: sourceId,
|
||||
paint: {
|
||||
'line-color': ann.color,
|
||||
'line-width': 3,
|
||||
'line-opacity': 0.8,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Error rendering area:', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up removed annotation layers
|
||||
currentIds.forEach((id) => {
|
||||
const ann = shape.props.annotations.find((a: Annotation) => a.id === id);
|
||||
if (!ann) {
|
||||
try {
|
||||
if (map.getLayer(`ann-layer-${id}`)) map.removeLayer(`ann-layer-${id}`);
|
||||
if (map.getLayer(`ann-fill-${id}`)) map.removeLayer(`ann-fill-${id}`);
|
||||
if (map.getSource(`ann-source-${id}`)) map.removeSource(`ann-source-${id}`);
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
});
|
||||
}, [shape.props.annotations, isLoaded]);
|
||||
|
||||
// ==========================================================================
|
||||
// Drawing Preview (for lines/areas in progress)
|
||||
// ==========================================================================
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || !isLoaded || !isMountedRef.current) return;
|
||||
|
||||
const map = mapRef.current;
|
||||
const sourceId = 'drawing-preview';
|
||||
const lineLayerId = 'drawing-preview-line';
|
||||
const fillLayerId = 'drawing-preview-fill';
|
||||
const pointsLayerId = 'drawing-preview-points';
|
||||
|
||||
try {
|
||||
// Remove existing preview layers first
|
||||
if (map.getLayer(pointsLayerId)) map.removeLayer(pointsLayerId);
|
||||
if (map.getLayer(fillLayerId)) map.removeLayer(fillLayerId);
|
||||
if (map.getLayer(lineLayerId)) map.removeLayer(lineLayerId);
|
||||
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
||||
|
||||
if (drawingPoints.length === 0) return;
|
||||
|
||||
const coords = drawingPoints.map((c) => [c.lng, c.lat]);
|
||||
|
||||
if (activeTool === 'line' && coords.length >= 1) {
|
||||
map.addSource(sourceId, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
...(coords.length >= 2 ? [{
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'LineString' as const, coordinates: coords },
|
||||
}] : []),
|
||||
...coords.map((coord) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'Point' as const, coordinates: coord },
|
||||
})),
|
||||
],
|
||||
},
|
||||
});
|
||||
if (coords.length >= 2) {
|
||||
map.addLayer({
|
||||
id: lineLayerId,
|
||||
type: 'line',
|
||||
source: sourceId,
|
||||
paint: {
|
||||
'line-color': selectedColor,
|
||||
'line-width': 4,
|
||||
'line-opacity': 0.6,
|
||||
'line-dasharray': [2, 2],
|
||||
},
|
||||
});
|
||||
}
|
||||
map.addLayer({
|
||||
id: pointsLayerId,
|
||||
type: 'circle',
|
||||
source: sourceId,
|
||||
filter: ['==', '$type', 'Point'],
|
||||
paint: {
|
||||
'circle-radius': 6,
|
||||
'circle-color': selectedColor,
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#fff',
|
||||
},
|
||||
});
|
||||
} else if (activeTool === 'area' && coords.length >= 1) {
|
||||
const closedCoords = coords.length >= 3 ? [...coords, coords[0]] : coords;
|
||||
map.addSource(sourceId, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
...(coords.length >= 3 ? [{
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'Polygon' as const, coordinates: [closedCoords] },
|
||||
}] : []),
|
||||
...(coords.length >= 2 ? [{
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'LineString' as const, coordinates: coords },
|
||||
}] : []),
|
||||
...coords.map((coord) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'Point' as const, coordinates: coord },
|
||||
})),
|
||||
],
|
||||
},
|
||||
});
|
||||
if (coords.length >= 3) {
|
||||
map.addLayer({
|
||||
id: fillLayerId,
|
||||
type: 'fill',
|
||||
source: sourceId,
|
||||
filter: ['==', '$type', 'Polygon'],
|
||||
paint: {
|
||||
'fill-color': selectedColor,
|
||||
'fill-opacity': 0.2,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (coords.length >= 2) {
|
||||
map.addLayer({
|
||||
id: lineLayerId,
|
||||
type: 'line',
|
||||
source: sourceId,
|
||||
filter: ['==', '$type', 'LineString'],
|
||||
paint: {
|
||||
'line-color': selectedColor,
|
||||
'line-width': 3,
|
||||
'line-opacity': 0.6,
|
||||
'line-dasharray': [2, 2],
|
||||
},
|
||||
});
|
||||
}
|
||||
map.addLayer({
|
||||
id: pointsLayerId,
|
||||
type: 'circle',
|
||||
source: sourceId,
|
||||
filter: ['==', '$type', 'Point'],
|
||||
paint: {
|
||||
'circle-radius': 6,
|
||||
'circle-color': selectedColor,
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#fff',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Error rendering drawing preview:', err);
|
||||
}
|
||||
}, [drawingPoints, activeTool, selectedColor, isLoaded]);
|
||||
|
||||
// ==========================================================================
|
||||
// Collaborator presence (cursors/locations)
|
||||
// ==========================================================================
|
||||
|
|
@ -1121,8 +1504,20 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
|
|||
borderRadius: '50%',
|
||||
background: ann.color,
|
||||
}} />
|
||||
<div style={{ flex: 1, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{ann.name}
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{ fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{ann.name}
|
||||
</div>
|
||||
{ann.type === 'line' && ann.coordinates.length >= 2 && (
|
||||
<div style={{ fontSize: 10, color: '#666', marginTop: 1 }}>
|
||||
📏 {formatDistance(calculateDistance(ann.coordinates))}
|
||||
</div>
|
||||
)}
|
||||
{ann.type === 'area' && ann.coordinates.length >= 3 && (
|
||||
<div style={{ fontSize: 10, color: '#666', marginTop: 1 }}>
|
||||
⬡ {formatArea(calculateArea(ann.coordinates))} • {formatDistance(calculateDistance([...ann.coordinates, ann.coordinates[0]]))} perimeter
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleAnnotationVisibility(ann.id); }}
|
||||
|
|
@ -1273,6 +1668,43 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Measurement Display and Drawing Instructions */}
|
||||
{(activeTool === 'line' || activeTool === 'area') && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 80,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: '#fff',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.15)',
|
||||
padding: '8px 14px',
|
||||
zIndex: 10000,
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{drawingPoints.length > 0 && (
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: selectedColor }}>
|
||||
{activeTool === 'line' && formatDistance(calculateDistance(drawingPoints))}
|
||||
{activeTool === 'area' && drawingPoints.length >= 3 && formatArea(calculateArea(drawingPoints))}
|
||||
{activeTool === 'area' && drawingPoints.length < 3 && `${drawingPoints.length}/3 points`}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: '#666' }}>
|
||||
{drawingPoints.length === 0 && 'Click to start drawing'}
|
||||
{drawingPoints.length > 0 && activeTool === 'line' && drawingPoints.length < 2 && 'Click to add more points'}
|
||||
{drawingPoints.length >= 2 && activeTool === 'line' && 'Double-click to finish line'}
|
||||
{drawingPoints.length > 0 && activeTool === 'area' && drawingPoints.length < 3 && 'Click to add more points'}
|
||||
{drawingPoints.length >= 3 && activeTool === 'area' && 'Double-click to finish area'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drawing Toolbar (Mapus-style) */}
|
||||
<div style={styles.toolbar} onPointerDown={stopPropagation}>
|
||||
{/* Cursor Tool */}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ import { useDialogs } from "tldraw"
|
|||
import { SettingsDialog } from "./SettingsDialog"
|
||||
import { useAuth } from "../context/AuthContext"
|
||||
import LoginButton from "../components/auth/LoginButton"
|
||||
import StarBoardButton from "../components/StarBoardButton"
|
||||
import ShareBoardButton from "../components/ShareBoardButton"
|
||||
import { ObsidianVaultBrowser } from "../components/ObsidianVaultBrowser"
|
||||
import { HolonBrowser } from "../components/HolonBrowser"
|
||||
import { ObsNoteShape } from "../shapes/ObsNoteShapeUtil"
|
||||
|
|
@ -645,790 +643,7 @@ export function CustomToolbar() {
|
|||
if (!isReady) return null
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<div
|
||||
className="toolbar-container"
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
<LoginButton className="toolbar-btn" />
|
||||
<ShareBoardButton className="toolbar-btn" />
|
||||
<StarBoardButton className="toolbar-btn" />
|
||||
{session.authed && (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
className="toolbar-btn profile-btn"
|
||||
onClick={() => setShowProfilePopup(!showProfilePopup)}
|
||||
title={`Signed in as ${session.username}`}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>
|
||||
</svg>
|
||||
<span className="profile-username">{session.username}</span>
|
||||
</button>
|
||||
|
||||
{showProfilePopup && (
|
||||
<div ref={profilePopupRef} className="profile-dropdown" style={{ width: '280px', maxHeight: '80vh', overflowY: 'auto' }}>
|
||||
<div className="profile-dropdown-header">
|
||||
<div className="profile-avatar">
|
||||
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="profile-info">
|
||||
<span className="profile-name">{session.username}</span>
|
||||
<span className="profile-label">CryptID Account</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-dropdown-divider" />
|
||||
|
||||
<a href="/dashboard/" className="profile-dropdown-item">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09z"/>
|
||||
</svg>
|
||||
<span>My Saved Boards</span>
|
||||
</a>
|
||||
|
||||
<div className="profile-dropdown-divider" />
|
||||
|
||||
{/* General Settings */}
|
||||
<button className="profile-dropdown-item" onClick={toggleDarkMode}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
{isDarkMode ? (
|
||||
<path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm.5-9.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm0 11a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm5-5a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-11 0a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm9.743-4.036a.5.5 0 1 1-.707-.707.5.5 0 0 1 .707.707zm-7.779 7.779a.5.5 0 1 1-.707-.707.5.5 0 0 1 .707.707zm7.072 0a.5.5 0 1 1 .707-.707.5.5 0 0 1-.707.707zM3.757 4.464a.5.5 0 1 1 .707-.707.5.5 0 0 1-.707.707z"/>
|
||||
) : (
|
||||
<path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
|
||||
)}
|
||||
</svg>
|
||||
<span>{isDarkMode ? 'Light Mode' : 'Dark Mode'}</span>
|
||||
</button>
|
||||
|
||||
<div className="profile-dropdown-divider" />
|
||||
|
||||
{/* AI Models Section */}
|
||||
<button
|
||||
className="profile-dropdown-item"
|
||||
onClick={() => setExpandedSection(expandedSection === 'ai' ? 'none' : 'ai')}
|
||||
style={{ justifyContent: 'space-between' }}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '14px' }}>🤖</span>
|
||||
<span>AI Models</span>
|
||||
</span>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
style={{ transform: expandedSection === 'ai' ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
|
||||
>
|
||||
<path d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{expandedSection === 'ai' && (
|
||||
<div style={{ padding: '8px 12px', backgroundColor: 'var(--color-muted-2, #f5f5f5)' }}>
|
||||
<p style={{ fontSize: '10px', color: 'var(--color-text-2, #666)', marginBottom: '8px' }}>
|
||||
Local models are free. Cloud models require API keys.
|
||||
</p>
|
||||
{AI_TOOLS.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '6px 0',
|
||||
borderBottom: '1px solid var(--color-muted-1, #eee)',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
|
||||
<span>{tool.icon}</span>
|
||||
<span>{tool.name}</span>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '9px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '10px',
|
||||
backgroundColor: tool.type === 'local' ? '#d1fae5' : tool.type === 'gpu' ? '#e0e7ff' : '#fef3c7',
|
||||
color: tool.type === 'local' ? '#065f46' : tool.type === 'gpu' ? '#3730a3' : '#92400e',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{tool.model}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
addDialog({
|
||||
id: "api-keys",
|
||||
component: ({ onClose: dialogClose }: { onClose: () => void }) => (
|
||||
<SettingsDialog
|
||||
onClose={() => {
|
||||
dialogClose()
|
||||
removeDialog("api-keys")
|
||||
checkApiKeys()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: '8px',
|
||||
padding: '6px 10px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
backgroundColor: 'var(--color-primary, #3b82f6)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{hasApiKey ? 'Manage API Keys' : 'Add API Keys'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integrations Section */}
|
||||
<button
|
||||
className="profile-dropdown-item"
|
||||
onClick={() => setExpandedSection(expandedSection === 'integrations' ? 'none' : 'integrations')}
|
||||
style={{ justifyContent: 'space-between' }}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '14px' }}>🔗</span>
|
||||
<span>Integrations</span>
|
||||
</span>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
style={{ transform: expandedSection === 'integrations' ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
|
||||
>
|
||||
<path d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{expandedSection === 'integrations' && (
|
||||
<div style={{ padding: '8px 12px', backgroundColor: 'var(--color-muted-2, #f5f5f5)' }}>
|
||||
{/* Obsidian Vault */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', fontWeight: 500 }}>
|
||||
<span>📁</span> Obsidian Vault
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '9px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '10px',
|
||||
backgroundColor: session.obsidianVaultName ? '#d1fae5' : '#fef3c7',
|
||||
color: session.obsidianVaultName ? '#065f46' : '#92400e',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{session.obsidianVaultName ? 'Connected' : 'Not Set'}
|
||||
</span>
|
||||
</div>
|
||||
{session.obsidianVaultName && (
|
||||
<p style={{ fontSize: '10px', color: '#059669', marginBottom: '4px' }}>{session.obsidianVaultName}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('open-obsidian-browser'))
|
||||
setShowProfilePopup(false)
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '5px 8px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{session.obsidianVaultName ? 'Change Vault' : 'Connect Vault'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Fathom Meetings */}
|
||||
<div style={{ paddingTop: '8px', borderTop: '1px solid var(--color-muted-1, #ddd)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', fontWeight: 500 }}>
|
||||
<span>🎥</span> Fathom Meetings
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '9px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '10px',
|
||||
backgroundColor: hasFathomApiKey ? '#d1fae5' : '#fef3c7',
|
||||
color: hasFathomApiKey ? '#065f46' : '#92400e',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{hasFathomApiKey ? 'Connected' : 'Not Set'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showFathomInput ? (
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
value={fathomKeyInput}
|
||||
onChange={(e) => setFathomKeyInput(e.target.value)}
|
||||
placeholder="Enter Fathom API key..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
fontSize: '11px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && fathomKeyInput.trim()) {
|
||||
saveFathomApiKey(fathomKeyInput.trim(), session.username)
|
||||
setHasFathomApiKey(true)
|
||||
setShowFathomInput(false)
|
||||
setFathomKeyInput('')
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowFathomInput(false)
|
||||
setFathomKeyInput('')
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (fathomKeyInput.trim()) {
|
||||
saveFathomApiKey(fathomKeyInput.trim(), session.username)
|
||||
setHasFathomApiKey(true)
|
||||
setShowFathomInput(false)
|
||||
setFathomKeyInput('')
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: 'var(--color-primary, #3b82f6)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFathomInput(false)
|
||||
setFathomKeyInput('')
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
href="https://app.usefathom.com/settings/integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ display: 'block', fontSize: '9px', color: '#3b82f6', marginTop: '6px', textDecoration: 'none' }}
|
||||
>
|
||||
Get API key from Fathom →
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFathomInput(true)
|
||||
const currentKey = getFathomApiKey(session.username)
|
||||
if (currentKey) setFathomKeyInput(currentKey)
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px 8px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{hasFathomApiKey ? 'Change Key' : 'Add API Key'}
|
||||
</button>
|
||||
{hasFathomApiKey && (
|
||||
<button
|
||||
onClick={() => {
|
||||
removeFathomApiKey(session.username)
|
||||
setHasFathomApiKey(false)
|
||||
}}
|
||||
style={{
|
||||
padding: '5px 8px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: '#fee2e2',
|
||||
color: '#dc2626',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connections Section */}
|
||||
<button
|
||||
className="profile-dropdown-item"
|
||||
onClick={() => setExpandedSection(expandedSection === 'connections' ? 'none' : 'connections')}
|
||||
style={{ justifyContent: 'space-between' }}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '14px' }}>🕸️</span>
|
||||
<span>Connections</span>
|
||||
{connections.length > 0 && (
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
padding: '1px 6px',
|
||||
borderRadius: '10px',
|
||||
backgroundColor: 'var(--color-muted-2, #e5e7eb)',
|
||||
color: 'var(--color-text-2, #666)',
|
||||
}}>
|
||||
{connections.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
style={{ transform: expandedSection === 'connections' ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
|
||||
>
|
||||
<path d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{expandedSection === 'connections' && (
|
||||
<div style={{ padding: '8px 12px', backgroundColor: 'var(--color-muted-2, #f5f5f5)', maxHeight: '400px', overflowY: 'auto' }}>
|
||||
{/* People in Canvas Section */}
|
||||
{canvasUsers.length > 0 && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-2, #666)', marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span>People in Canvas</span>
|
||||
<span style={{
|
||||
fontSize: '9px',
|
||||
padding: '1px 5px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'var(--color-primary, #3b82f6)',
|
||||
color: 'white',
|
||||
}}>
|
||||
{canvasUsers.length}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{canvasUsers.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--color-muted-1, #e5e7eb)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{/* User avatar with presence color */}
|
||||
<div
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: user.color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '11px',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
border: `2px solid ${
|
||||
user.connectionStatus === 'trusted' ? TRUST_LEVEL_COLORS.trusted :
|
||||
user.connectionStatus === 'connected' ? TRUST_LEVEL_COLORS.connected :
|
||||
TRUST_LEVEL_COLORS.unconnected
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', fontWeight: 500 }}>
|
||||
{user.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: 'var(--color-text-2, #666)' }}>
|
||||
{user.connectionStatus === 'trusted' ? 'Trusted' :
|
||||
user.connectionStatus === 'connected' ? 'Connected' :
|
||||
'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection status indicator & actions */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
{connectingUserId === user.id ? (
|
||||
<span style={{ fontSize: '10px', color: 'var(--color-text-2, #666)' }}>...</span>
|
||||
) : user.connectionStatus === 'unconnected' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleConnect(user.id, 'connected')}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: TRUST_LEVEL_COLORS.connected,
|
||||
color: 'black',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Add as Connected (view access)"
|
||||
>
|
||||
+ Connect
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleConnect(user.id, 'trusted')}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: TRUST_LEVEL_COLORS.trusted,
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Add as Trusted (edit access)"
|
||||
>
|
||||
+ Trust
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Toggle between connected and trusted */}
|
||||
{user.connectionStatus === 'connected' ? (
|
||||
<button
|
||||
onClick={() => handleChangeTrust(user.connectionId!, user.id, 'trusted')}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: TRUST_LEVEL_COLORS.trusted,
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Upgrade to Trusted (edit access)"
|
||||
>
|
||||
Trust
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleChangeTrust(user.connectionId!, user.id, 'connected')}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: TRUST_LEVEL_COLORS.connected,
|
||||
color: 'black',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Downgrade to Connected (view only)"
|
||||
>
|
||||
Demote
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDisconnect(user.connectionId!, user.id)}
|
||||
style={{
|
||||
padding: '3px 6px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: '#fee2e2',
|
||||
color: '#dc2626',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Remove connection"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider if both sections exist */}
|
||||
{canvasUsers.length > 0 && connections.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid var(--color-muted-1, #ddd)', marginBottom: '12px' }} />
|
||||
)}
|
||||
|
||||
{/* My Connections Section */}
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-2, #666)', marginBottom: '8px' }}>
|
||||
My Connections
|
||||
</div>
|
||||
|
||||
{connectionsLoading ? (
|
||||
<p style={{ fontSize: '11px', color: 'var(--color-text-2, #666)', textAlign: 'center', padding: '12px 0' }}>
|
||||
Loading connections...
|
||||
</p>
|
||||
) : connections.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '12px 0' }}>
|
||||
<p style={{ fontSize: '11px', color: 'var(--color-text-2, #666)', marginBottom: '8px' }}>
|
||||
No connections yet
|
||||
</p>
|
||||
<p style={{ fontSize: '10px', color: 'var(--color-text-3, #999)' }}>
|
||||
Connect with people in the canvas above
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{connections.map((conn) => (
|
||||
<div
|
||||
key={conn.id}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--color-muted-1, #e5e7eb)',
|
||||
}}
|
||||
>
|
||||
{/* Connection Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '6px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: conn.toProfile?.avatarColor || TRUST_LEVEL_COLORS[conn.trustLevel],
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '10px',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{(conn.toProfile?.displayName || conn.toUserId).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', fontWeight: 500 }}>
|
||||
{conn.toProfile?.displayName || conn.toUserId}
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: 'var(--color-text-2, #666)' }}>
|
||||
@{conn.toUserId}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '9px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '10px',
|
||||
backgroundColor: conn.trustLevel === 'trusted' ? '#d1fae5' : '#fef3c7',
|
||||
color: conn.trustLevel === 'trusted' ? '#065f46' : '#92400e',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{conn.trustLevel === 'trusted' ? 'Trusted' : 'Connected'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Mutual Connection Badge */}
|
||||
{conn.isMutual && (
|
||||
<div style={{
|
||||
fontSize: '9px',
|
||||
color: '#059669',
|
||||
backgroundColor: '#d1fae5',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '6px',
|
||||
display: 'inline-block',
|
||||
}}>
|
||||
✓ Mutual connection
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edge Metadata Display/Edit */}
|
||||
{editingConnectionId === conn.id ? (
|
||||
<div style={{ marginTop: '8px', padding: '8px', backgroundColor: 'var(--color-muted-2, #f5f5f5)', borderRadius: '4px' }}>
|
||||
<div style={{ marginBottom: '6px' }}>
|
||||
<label style={{ fontSize: '10px', color: 'var(--color-text-2, #666)', display: 'block', marginBottom: '2px' }}>Label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingMetadata.label || ''}
|
||||
onChange={(e) => setEditingMetadata({ ...editingMetadata, label: e.target.value })}
|
||||
placeholder="e.g., Colleague, Friend..."
|
||||
style={{ width: '100%', padding: '4px 6px', fontSize: '11px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '6px' }}>
|
||||
<label style={{ fontSize: '10px', color: 'var(--color-text-2, #666)', display: 'block', marginBottom: '2px' }}>Notes (private)</label>
|
||||
<textarea
|
||||
value={editingMetadata.notes || ''}
|
||||
onChange={(e) => setEditingMetadata({ ...editingMetadata, notes: e.target.value })}
|
||||
placeholder="Private notes about this connection..."
|
||||
style={{ width: '100%', padding: '4px 6px', fontSize: '11px', border: '1px solid #ddd', borderRadius: '4px', minHeight: '50px', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '6px' }}>
|
||||
<label style={{ fontSize: '10px', color: 'var(--color-text-2, #666)', display: 'block', marginBottom: '2px' }}>Strength (1-10)</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="10"
|
||||
value={editingMetadata.strength || 5}
|
||||
onChange={(e) => setEditingMetadata({ ...editingMetadata, strength: parseInt(e.target.value) })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<div style={{ fontSize: '10px', textAlign: 'center', color: 'var(--color-text-2, #666)' }}>{editingMetadata.strength || 5}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px', marginTop: '8px' }}>
|
||||
<button
|
||||
onClick={() => handleSaveMetadata(conn.id)}
|
||||
disabled={savingMetadata}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: 'var(--color-primary, #3b82f6)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: savingMetadata ? 'not-allowed' : 'pointer',
|
||||
opacity: savingMetadata ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{savingMetadata ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingConnectionId(null)
|
||||
setEditingMetadata({})
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Show existing metadata if any */}
|
||||
{conn.metadata && (conn.metadata.label || conn.metadata.notes) && (
|
||||
<div style={{ marginTop: '6px', padding: '6px', backgroundColor: 'var(--color-muted-2, #f5f5f5)', borderRadius: '4px' }}>
|
||||
{conn.metadata.label && (
|
||||
<div style={{ fontSize: '11px', fontWeight: 500, marginBottom: '2px' }}>
|
||||
{conn.metadata.label}
|
||||
</div>
|
||||
)}
|
||||
{conn.metadata.notes && (
|
||||
<div style={{ fontSize: '10px', color: 'var(--color-text-2, #666)' }}>
|
||||
{conn.metadata.notes}
|
||||
</div>
|
||||
)}
|
||||
{conn.metadata.strength && (
|
||||
<div style={{ fontSize: '9px', color: 'var(--color-text-3, #999)', marginTop: '4px' }}>
|
||||
Strength: {conn.metadata.strength}/10
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingConnectionId(conn.id)
|
||||
setEditingMetadata(conn.metadata || {})
|
||||
}}
|
||||
style={{
|
||||
marginTop: '6px',
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px dashed #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--color-text-2, #666)',
|
||||
}}
|
||||
>
|
||||
{conn.metadata?.label || conn.metadata?.notes ? 'Edit details' : 'Add details'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="profile-dropdown-divider" />
|
||||
|
||||
{!session.backupCreated && (
|
||||
<div className="profile-dropdown-warning">
|
||||
Back up your encryption keys to prevent data loss
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="profile-dropdown-item danger" onClick={handleLogout}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z"/>
|
||||
<path fillRule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"/>
|
||||
</svg>
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<DefaultToolbar>
|
||||
<DefaultToolbarContent />
|
||||
{tools["VideoChat"] && (
|
||||
|
|
@ -1572,14 +787,13 @@ export function CustomToolbar() {
|
|||
)
|
||||
})()}
|
||||
</DefaultToolbar>
|
||||
|
||||
|
||||
{/* Fathom Meetings Panel */}
|
||||
{showFathomPanel && (
|
||||
<FathomMeetingsPanel
|
||||
onClose={() => setShowFathomPanel(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,24 +16,48 @@ interface InviteDialogProps extends TLUiDialogProps {
|
|||
boardSlug: string
|
||||
}
|
||||
|
||||
type TabType = 'qr' | 'url' | 'nfc' | 'audio'
|
||||
type PermissionType = 'view' | 'edit' | 'admin'
|
||||
|
||||
const PERMISSION_LABELS: Record<PermissionType, { label: string; description: string; color: string }> = {
|
||||
view: { label: 'View', description: 'Can view but not edit', color: '#6b7280' },
|
||||
edit: { label: 'Edit', description: 'Can view and edit', color: '#3b82f6' },
|
||||
admin: { label: 'Admin', description: 'Full control', color: '#10b981' },
|
||||
}
|
||||
|
||||
export function InviteDialog({ onClose, boardUrl, boardSlug }: InviteDialogProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('qr')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [nfcStatus, setNfcStatus] = useState<'idle' | 'writing' | 'success' | 'error' | 'unsupported'>('idle')
|
||||
const [nfcMessage, setNfcMessage] = useState('')
|
||||
const [permission, setPermission] = useState<PermissionType>('edit')
|
||||
|
||||
// Check NFC support on mount
|
||||
// Check NFC support on mount and add ESC key handler
|
||||
useEffect(() => {
|
||||
if (!('NDEFReader' in window)) {
|
||||
setNfcStatus('unsupported')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ESC key handler to close dialog
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
||||
}, [onClose])
|
||||
|
||||
// Generate URL with permission parameter
|
||||
const getShareUrl = () => {
|
||||
const url = new URL(boardUrl)
|
||||
url.searchParams.set('access', permission)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(boardUrl)
|
||||
await navigator.clipboard.writeText(getShareUrl())
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
|
|
@ -55,7 +79,7 @@ export function InviteDialog({ onClose, boardUrl, boardSlug }: InviteDialogProps
|
|||
const ndef = new (window as any).NDEFReader()
|
||||
await ndef.write({
|
||||
records: [
|
||||
{ recordType: "url", data: boardUrl }
|
||||
{ recordType: "url", data: getShareUrl() }
|
||||
]
|
||||
})
|
||||
|
||||
|
|
@ -78,26 +102,13 @@ export function InviteDialog({ onClose, boardUrl, boardSlug }: InviteDialogProps
|
|||
}
|
||||
}
|
||||
|
||||
const tabStyle = (tab: TabType) => ({
|
||||
flex: 1,
|
||||
padding: '10px 16px',
|
||||
border: 'none',
|
||||
background: activeTab === tab ? '#3b82f6' : '#f3f4f6',
|
||||
color: activeTab === tab ? 'white' : '#374151',
|
||||
cursor: 'pointer',
|
||||
fontWeight: activeTab === tab ? 600 : 400,
|
||||
fontSize: '13px',
|
||||
transition: 'all 0.2s ease',
|
||||
borderRadius: tab === 'qr' ? '6px 0 0 6px' : tab === 'audio' ? '0 6px 6px 0' : '0',
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<TldrawUiDialogHeader>
|
||||
<TldrawUiDialogTitle>Invite to Board</TldrawUiDialogTitle>
|
||||
<TldrawUiDialogCloseButton />
|
||||
</TldrawUiDialogHeader>
|
||||
<TldrawUiDialogBody style={{ maxWidth: 420, minHeight: 380 }}>
|
||||
<TldrawUiDialogBody style={{ maxWidth: 420 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{/* Board name display */}
|
||||
<div style={{
|
||||
|
|
@ -111,228 +122,230 @@ export function InviteDialog({ onClose, boardUrl, boardSlug }: InviteDialogProps
|
|||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#1e293b' }}>{boardSlug}</span>
|
||||
</div>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<div style={{ display: 'flex', borderRadius: '6px', overflow: 'hidden' }}>
|
||||
<button style={tabStyle('qr')} onClick={() => setActiveTab('qr')}>
|
||||
QR Code
|
||||
</button>
|
||||
<button style={tabStyle('url')} onClick={() => setActiveTab('url')}>
|
||||
URL
|
||||
</button>
|
||||
<button style={tabStyle('nfc')} onClick={() => setActiveTab('nfc')}>
|
||||
NFC
|
||||
</button>
|
||||
<button style={tabStyle('audio')} onClick={() => setActiveTab('audio')}>
|
||||
Audio
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{/* Permission selector */}
|
||||
<div style={{
|
||||
minHeight: 220,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<span style={{ fontSize: '12px', color: '#64748b', fontWeight: 500 }}>Access Level</span>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{(['view', 'edit', 'admin'] as PermissionType[]).map((perm) => {
|
||||
const isActive = permission === perm
|
||||
const { label, description, color } = PERMISSION_LABELS[perm]
|
||||
return (
|
||||
<button
|
||||
key={perm}
|
||||
onClick={() => setPermission(perm)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 8px',
|
||||
border: isActive ? `2px solid ${color}` : '2px solid #e5e7eb',
|
||||
background: isActive ? `${color}10` : 'white',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.borderColor = color
|
||||
e.currentTarget.style.background = `${color}08`
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
e.currentTarget.style.background = 'white'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: isActive ? color : '#374151'
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: '#9ca3af',
|
||||
lineHeight: 1.2,
|
||||
}}>
|
||||
{description}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR Code and URL side by side */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{activeTab === 'qr' && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
display: 'inline-block'
|
||||
}}>
|
||||
<QRCodeSVG
|
||||
value={boardUrl}
|
||||
size={180}
|
||||
level="M"
|
||||
includeMargin={false}
|
||||
/>
|
||||
</div>
|
||||
<p style={{
|
||||
marginTop: '16px',
|
||||
fontSize: '13px',
|
||||
color: '#6b7280',
|
||||
lineHeight: 1.5
|
||||
}}>
|
||||
Scan this QR code with a mobile device to join the board
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* QR Code */}
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.1)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<QRCodeSVG
|
||||
value={getShareUrl()}
|
||||
size={120}
|
||||
level="M"
|
||||
includeMargin={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{activeTab === 'url' && (
|
||||
<div style={{ width: '100%', textAlign: 'center' }}>
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #d1d5db',
|
||||
marginBottom: '16px',
|
||||
wordBreak: 'break-all',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'monospace',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{boardUrl}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopyUrl}
|
||||
style={{
|
||||
padding: '10px 24px',
|
||||
backgroundColor: copied ? '#10b981' : '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
margin: '0 auto'
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<span>Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Copy URL</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<p style={{
|
||||
marginTop: '16px',
|
||||
fontSize: '13px',
|
||||
color: '#6b7280'
|
||||
}}>
|
||||
Share this link with anyone to invite them to your board
|
||||
</p>
|
||||
{/* URL and Copy Button */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: '12px' }}>
|
||||
<div style={{
|
||||
padding: '10px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #d1d5db',
|
||||
wordBreak: 'break-all',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'monospace',
|
||||
color: '#374151',
|
||||
lineHeight: 1.4,
|
||||
maxHeight: '60px',
|
||||
overflowY: 'auto',
|
||||
}}>
|
||||
{getShareUrl()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'nfc' && (
|
||||
<div style={{ width: '100%', textAlign: 'center' }}>
|
||||
{nfcStatus === 'unsupported' ? (
|
||||
<button
|
||||
onClick={handleCopyUrl}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: copied ? '#10b981' : '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<div style={{
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
opacity: 0.5
|
||||
}}>
|
||||
NFC
|
||||
</div>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
NFC is not supported on this device
|
||||
</p>
|
||||
<p style={{
|
||||
fontSize: '12px',
|
||||
color: '#9ca3af'
|
||||
}}>
|
||||
Try using a mobile device with NFC capability
|
||||
</p>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
<span>Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
animation: nfcStatus === 'writing' ? 'pulse 1.5s infinite' : 'none'
|
||||
}}>
|
||||
{nfcStatus === 'success' ? '(done)' : nfcStatus === 'error' ? '(!)' : 'NFC'}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleNfcWrite}
|
||||
disabled={nfcStatus === 'writing'}
|
||||
style={{
|
||||
padding: '10px 24px',
|
||||
backgroundColor: nfcStatus === 'success' ? '#10b981' :
|
||||
nfcStatus === 'error' ? '#ef4444' :
|
||||
nfcStatus === 'writing' ? '#9ca3af' : '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: nfcStatus === 'writing' ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '12px'
|
||||
}}
|
||||
>
|
||||
{nfcStatus === 'writing' ? 'Writing...' :
|
||||
nfcStatus === 'success' ? 'Written!' :
|
||||
'Write to NFC Tag'}
|
||||
</button>
|
||||
{nfcMessage && (
|
||||
<p style={{
|
||||
fontSize: '13px',
|
||||
color: nfcStatus === 'error' ? '#ef4444' :
|
||||
nfcStatus === 'success' ? '#10b981' : '#6b7280'
|
||||
}}>
|
||||
{nfcMessage}
|
||||
</p>
|
||||
)}
|
||||
{!nfcMessage && (
|
||||
<p style={{
|
||||
fontSize: '13px',
|
||||
color: '#6b7280'
|
||||
}}>
|
||||
Write the board URL to an NFC tag for instant access
|
||||
</p>
|
||||
)}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<span>Copy Link</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'audio' && (
|
||||
<div style={{ width: '100%', textAlign: 'center' }}>
|
||||
<div style={{
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
opacity: 0.6
|
||||
}}>
|
||||
((( )))
|
||||
</div>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#374151',
|
||||
fontWeight: 500,
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
Audio Connect
|
||||
</p>
|
||||
<p style={{
|
||||
fontSize: '13px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
Share the board link via ultrasonic audio
|
||||
</p>
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
backgroundColor: '#fef3c7',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #fcd34d',
|
||||
fontSize: '12px',
|
||||
color: '#92400e'
|
||||
}}>
|
||||
Coming soon! Audio-based sharing will allow nearby devices to join by listening for an ultrasonic signal.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced sharing options (collapsed) */}
|
||||
<details style={{ marginTop: '4px' }}>
|
||||
<summary style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
padding: '4px 0',
|
||||
}}>
|
||||
More sharing options (NFC, Audio)
|
||||
</summary>
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
}}>
|
||||
{/* NFC Button */}
|
||||
<button
|
||||
onClick={handleNfcWrite}
|
||||
disabled={nfcStatus === 'unsupported' || nfcStatus === 'writing'}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
backgroundColor: nfcStatus === 'unsupported' ? '#f3f4f6' :
|
||||
nfcStatus === 'success' ? '#d1fae5' :
|
||||
nfcStatus === 'error' ? '#fee2e2' :
|
||||
nfcStatus === 'writing' ? '#e0e7ff' : 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
cursor: nfcStatus === 'unsupported' || nfcStatus === 'writing' ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
opacity: nfcStatus === 'unsupported' ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '20px' }}>
|
||||
{nfcStatus === 'success' ? '✓' : nfcStatus === 'error' ? '!' : '📡'}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: '#374151', fontWeight: 500 }}>
|
||||
{nfcStatus === 'writing' ? 'Writing...' :
|
||||
nfcStatus === 'success' ? 'Written!' :
|
||||
nfcStatus === 'unsupported' ? 'NFC Unavailable' :
|
||||
'Write to NFC'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Audio Button (coming soon) */}
|
||||
<button
|
||||
disabled
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
backgroundColor: '#f3f4f6',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
cursor: 'not-allowed',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '20px' }}>🔊</span>
|
||||
<span style={{ fontSize: '11px', color: '#374151', fontWeight: 500 }}>
|
||||
Audio (Soon)
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{nfcMessage && (
|
||||
<p style={{
|
||||
marginTop: '8px',
|
||||
fontSize: '11px',
|
||||
color: nfcStatus === 'error' ? '#ef4444' :
|
||||
nfcStatus === 'success' ? '#10b981' : '#6b7280',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{nfcMessage}
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
</div>
|
||||
</TldrawUiDialogBody>
|
||||
<TldrawUiDialogFooter>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { UserSettingsModal } from "./UserSettingsModal"
|
|||
import { NetworkGraphPanel } from "../components/networking"
|
||||
import CryptIDDropdown from "../components/auth/CryptIDDropdown"
|
||||
import StarBoardButton from "../components/StarBoardButton"
|
||||
import ShareBoardButton from "../components/ShareBoardButton"
|
||||
import { SettingsDialog } from "./SettingsDialog"
|
||||
import {
|
||||
DefaultKeyboardShortcutsDialog,
|
||||
DefaultKeyboardShortcutsDialogContent,
|
||||
|
|
@ -16,22 +18,69 @@ import {
|
|||
TldrawUiMenuItem,
|
||||
useTools,
|
||||
useActions,
|
||||
useDialogs,
|
||||
} from "tldraw"
|
||||
import { SlidesPanel } from "@/slides/SlidesPanel"
|
||||
|
||||
// AI tool model configurations
|
||||
const AI_TOOLS = [
|
||||
{ id: 'chat', name: 'Chat', icon: '💬', model: 'llama3.1:8b', provider: 'Ollama', type: 'local' },
|
||||
{ id: 'make-real', name: 'Make Real', icon: '🔧', model: 'claude-sonnet-4-5', provider: 'Anthropic', type: 'cloud' },
|
||||
{ id: 'image-gen', name: 'Image Gen', icon: '🎨', model: 'SDXL', provider: 'RunPod', type: 'gpu' },
|
||||
{ id: 'video-gen', name: 'Video Gen', icon: '🎬', model: 'Wan2.1', provider: 'RunPod', type: 'gpu' },
|
||||
{ id: 'transcription', name: 'Transcribe', icon: '🎤', model: 'Web Speech', provider: 'Browser', type: 'local' },
|
||||
{ id: 'mycelial', name: 'Mycelial', icon: '🍄', model: 'llama3.1:70b', provider: 'Ollama', type: 'local' },
|
||||
];
|
||||
|
||||
// Custom SharePanel with layout: CryptID -> Star -> Gear -> Question mark
|
||||
function CustomSharePanel() {
|
||||
const tools = useTools()
|
||||
const actions = useActions()
|
||||
const { addDialog, removeDialog } = useDialogs()
|
||||
const [showShortcuts, setShowShortcuts] = React.useState(false)
|
||||
const [showSettings, setShowSettings] = React.useState(false)
|
||||
const [showSettingsDropdown, setShowSettingsDropdown] = React.useState(false)
|
||||
const [showAISection, setShowAISection] = React.useState(false)
|
||||
const [hasApiKey, setHasApiKey] = React.useState(false)
|
||||
|
||||
// ESC key handler for closing dropdowns
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (showSettingsDropdown) setShowSettingsDropdown(false)
|
||||
if (showShortcuts) setShowShortcuts(false)
|
||||
}
|
||||
}
|
||||
if (showSettingsDropdown || showShortcuts) {
|
||||
// Use capture phase to intercept before tldraw
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
}
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
||||
}, [showSettingsDropdown, showShortcuts])
|
||||
|
||||
// Detect dark mode - use state to trigger re-render on change
|
||||
const [isDarkMode, setIsDarkMode] = React.useState(
|
||||
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||
)
|
||||
|
||||
// Check for API keys on mount
|
||||
React.useEffect(() => {
|
||||
const checkApiKeys = () => {
|
||||
const keys = localStorage.getItem('apiKeys')
|
||||
if (keys) {
|
||||
try {
|
||||
const parsed = JSON.parse(keys)
|
||||
setHasApiKey(!!(parsed.openai || parsed.anthropic || parsed.google))
|
||||
} catch {
|
||||
setHasApiKey(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
checkApiKeys()
|
||||
}, [])
|
||||
|
||||
const handleToggleDarkMode = () => {
|
||||
const newIsDark = !document.documentElement.classList.contains('dark')
|
||||
document.documentElement.classList.toggle('dark')
|
||||
|
|
@ -39,6 +88,31 @@ function CustomSharePanel() {
|
|||
setIsDarkMode(newIsDark)
|
||||
}
|
||||
|
||||
const handleManageApiKeys = () => {
|
||||
setShowSettingsDropdown(false)
|
||||
addDialog({
|
||||
id: "api-keys",
|
||||
component: ({ onClose: dialogClose }: { onClose: () => void }) => (
|
||||
<SettingsDialog
|
||||
onClose={() => {
|
||||
dialogClose()
|
||||
removeDialog("api-keys")
|
||||
// Recheck API keys after dialog closes
|
||||
const keys = localStorage.getItem('apiKeys')
|
||||
if (keys) {
|
||||
try {
|
||||
const parsed = JSON.parse(keys)
|
||||
setHasApiKey(!!(parsed.openai || parsed.anthropic || parsed.google))
|
||||
} catch {
|
||||
setHasApiKey(false)
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
// Helper to extract label string from tldraw label (can be string or {default, menu} object)
|
||||
const getLabelString = (label: any, fallback: string): string => {
|
||||
if (typeof label === 'string') return label
|
||||
|
|
@ -147,6 +221,13 @@ function CustomSharePanel() {
|
|||
|
||||
<Separator />
|
||||
|
||||
{/* Share board button */}
|
||||
<div style={{ padding: '0 2px' }}>
|
||||
<ShareBoardButton className="share-panel-btn" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Star board button */}
|
||||
<div style={{ padding: '0 2px' }}>
|
||||
<StarBoardButton className="share-panel-btn" />
|
||||
|
|
@ -194,27 +275,36 @@ function CustomSharePanel() {
|
|||
{/* Settings dropdown */}
|
||||
{showSettingsDropdown && (
|
||||
<>
|
||||
{/* Backdrop - only uses onClick, not onPointerDown */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 99998,
|
||||
background: 'transparent',
|
||||
}}
|
||||
onClick={() => setShowSettingsDropdown(false)}
|
||||
/>
|
||||
{/* Dropdown menu */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 8px)',
|
||||
right: 0,
|
||||
minWidth: '200px',
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
background: 'var(--color-panel)',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
|
||||
zIndex: 99999,
|
||||
padding: '8px 0',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Dark mode toggle */}
|
||||
<button
|
||||
|
|
@ -259,6 +349,103 @@ function CustomSharePanel() {
|
|||
|
||||
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '4px 0' }} />
|
||||
|
||||
{/* AI Models expandable section */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowAISection(!showAISection)}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
padding: '10px 16px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--color-text)',
|
||||
fontSize: '13px',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--color-muted-2)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'none'
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style={{ fontSize: '16px' }}>🤖</span>
|
||||
<span>AI Models</span>
|
||||
</span>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
style={{ transform: showAISection ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
|
||||
>
|
||||
<path d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showAISection && (
|
||||
<div style={{ padding: '8px 16px', backgroundColor: 'var(--color-muted-2)' }}>
|
||||
<p style={{ fontSize: '10px', color: 'var(--color-text-3)', marginBottom: '8px' }}>
|
||||
Local models are free. Cloud models require API keys.
|
||||
</p>
|
||||
{AI_TOOLS.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '6px 0',
|
||||
borderBottom: '1px solid var(--color-panel-contrast)',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: 'var(--color-text)' }}>
|
||||
<span>{tool.icon}</span>
|
||||
<span>{tool.name}</span>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '9px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '10px',
|
||||
backgroundColor: tool.type === 'local' ? '#d1fae5' : tool.type === 'gpu' ? '#e0e7ff' : '#fef3c7',
|
||||
color: tool.type === 'local' ? '#065f46' : tool.type === 'gpu' ? '#3730a3' : '#92400e',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{tool.model}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={handleManageApiKeys}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: '8px',
|
||||
padding: '6px 10px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
backgroundColor: 'var(--color-primary, #3b82f6)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{hasApiKey ? 'Manage API Keys' : 'Add API Keys'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '4px 0' }} />
|
||||
|
||||
{/* All settings */}
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -341,29 +528,36 @@ function CustomSharePanel() {
|
|||
{/* Keyboard shortcuts panel */}
|
||||
{showShortcuts && (
|
||||
<>
|
||||
{/* Backdrop - only uses onClick, not onPointerDown */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 99998,
|
||||
background: 'transparent',
|
||||
}}
|
||||
onClick={() => setShowShortcuts(false)}
|
||||
/>
|
||||
{/* Shortcuts menu */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 8px)',
|
||||
right: 0,
|
||||
width: '320px',
|
||||
maxHeight: '70vh',
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
background: 'var(--color-panel)',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
|
||||
zIndex: 99999,
|
||||
padding: '12px 0',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{
|
||||
padding: '8px 16px 12px',
|
||||
|
|
|
|||
|
|
@ -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(request: Request): Response | Promise<Response> {
|
||||
|
|
@ -592,6 +736,23 @@ export class AutomergeDurableObject {
|
|||
}
|
||||
}
|
||||
|
||||
private broadcastToAll(message: any) {
|
||||
// Broadcast JSON messages to ALL connected clients (for system events like revert)
|
||||
let broadcastCount = 0
|
||||
for (const [sessionId, client] of this.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
console.log(`🔌 Worker: Broadcasting to ${sessionId}`)
|
||||
client.send(JSON.stringify(message))
|
||||
broadcastCount++
|
||||
} catch (error) {
|
||||
console.error(`❌ Worker: Error broadcasting to ${sessionId}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`🔌 Worker: Broadcast message to ALL ${broadcastCount} client(s)`)
|
||||
}
|
||||
|
||||
// Generate a fast hash of the document state for change detection
|
||||
// OPTIMIZED: Instead of JSON.stringify on entire document (expensive for large docs),
|
||||
// we hash based on record IDs, types, and metadata only
|
||||
|
|
|
|||
|
|
@ -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
|
||||
function generateUUID(): string {
|
||||
|
|
@ -7,22 +7,37 @@ function generateUUID(): string {
|
|||
|
||||
/**
|
||||
* Get a user's effective permission for a board
|
||||
* Priority: explicit permission > board owner (admin) > default permission
|
||||
* Priority: access token > explicit permission > board owner (admin) > default permission
|
||||
*
|
||||
* @param accessToken - Optional access token from share link (grants specific permission)
|
||||
*/
|
||||
export async function getEffectivePermission(
|
||||
db: D1Database,
|
||||
boardId: string,
|
||||
userId: string | null
|
||||
userId: string | null,
|
||||
accessToken?: string | null
|
||||
): Promise<PermissionCheckResult> {
|
||||
// Check if board exists
|
||||
const board = await db.prepare(
|
||||
'SELECT * FROM boards WHERE id = ?'
|
||||
).bind(boardId).first<Board>();
|
||||
|
||||
// Board doesn't exist - treat as new board, anyone authenticated can create
|
||||
// If an access token is provided, validate it and use its permission level
|
||||
if (accessToken) {
|
||||
const tokenPermission = await validateAccessToken(db, boardId, accessToken);
|
||||
if (tokenPermission) {
|
||||
return {
|
||||
permission: tokenPermission,
|
||||
isOwner: false,
|
||||
boardExists: !!board,
|
||||
grantedByToken: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Board doesn't exist in permissions DB
|
||||
// Anonymous users get VIEW by default, authenticated users can edit
|
||||
if (!board) {
|
||||
// If user is authenticated, they can create the board (will become owner)
|
||||
// If not authenticated, view-only (they'll see empty canvas but can't edit)
|
||||
return {
|
||||
permission: userId ? 'edit' : 'view',
|
||||
isOwner: false,
|
||||
|
|
@ -30,10 +45,11 @@ export async function getEffectivePermission(
|
|||
};
|
||||
}
|
||||
|
||||
// If user is not authenticated, return default permission
|
||||
// If user is not authenticated, return VIEW (secure by default)
|
||||
// To grant edit access to anonymous users, they must use a share link with access token
|
||||
if (!userId) {
|
||||
return {
|
||||
permission: board.default_permission as PermissionLevel,
|
||||
permission: 'view',
|
||||
isOwner: false,
|
||||
boardExists: true
|
||||
};
|
||||
|
|
@ -127,6 +143,7 @@ export async function ensureBoardExists(
|
|||
/**
|
||||
* GET /boards/:boardId/permission
|
||||
* Get current user's permission for a board
|
||||
* Query params: ?token=<access_token> - optional access token from share link
|
||||
*/
|
||||
export async function handleGetPermission(
|
||||
boardId: string,
|
||||
|
|
@ -136,9 +153,9 @@ export async function handleGetPermission(
|
|||
try {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
// No database - default to edit for backwards compatibility
|
||||
// No database - default to view for anonymous (secure by default)
|
||||
return new Response(JSON.stringify({
|
||||
permission: 'edit',
|
||||
permission: 'view',
|
||||
isOwner: false,
|
||||
boardExists: false,
|
||||
message: 'Permission system not configured'
|
||||
|
|
@ -161,7 +178,11 @@ export async function handleGetPermission(
|
|||
}
|
||||
}
|
||||
|
||||
const result = await getEffectivePermission(db, boardId, userId);
|
||||
// Get access token from query params if provided
|
||||
const url = new URL(request.url);
|
||||
const accessToken = url.searchParams.get('token');
|
||||
|
||||
const result = await getEffectivePermission(db, boardId, userId, accessToken);
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
|
@ -579,3 +600,304 @@ export async function handleUpdateBoard(
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Access Token Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure random token
|
||||
*/
|
||||
function generateAccessToken(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an access token and return the permission level if valid
|
||||
*/
|
||||
export async function validateAccessToken(
|
||||
db: D1Database,
|
||||
boardId: string,
|
||||
token: string
|
||||
): Promise<PermissionLevel | null> {
|
||||
const accessToken = await db.prepare(`
|
||||
SELECT * FROM board_access_tokens
|
||||
WHERE board_id = ? AND token = ? AND is_active = 1
|
||||
`).bind(boardId, token).first<BoardAccessToken>();
|
||||
|
||||
if (!accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (accessToken.expires_at) {
|
||||
const expiresAt = new Date(accessToken.expires_at);
|
||||
if (expiresAt < new Date()) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check max uses
|
||||
if (accessToken.max_uses !== null && accessToken.use_count >= accessToken.max_uses) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Increment use count
|
||||
await db.prepare(`
|
||||
UPDATE board_access_tokens SET use_count = use_count + 1 WHERE id = ?
|
||||
`).bind(accessToken.id).run();
|
||||
|
||||
return accessToken.permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /boards/:boardId/access-tokens
|
||||
* Create a new access token (admin only)
|
||||
* Body: { permission, label?, expiresIn?, maxUses? }
|
||||
*/
|
||||
export async function handleCreateAccessToken(
|
||||
boardId: string,
|
||||
request: Request,
|
||||
env: Environment
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return new Response(JSON.stringify({ error: 'Database not configured' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||
if (!publicKey) {
|
||||
return new Response(JSON.stringify({ error: 'Authentication required' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const deviceKey = await db.prepare(
|
||||
'SELECT user_id FROM device_keys WHERE public_key = ?'
|
||||
).bind(publicKey).first<{ user_id: string }>();
|
||||
|
||||
if (!deviceKey) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid credentials' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const permCheck = await getEffectivePermission(db, boardId, deviceKey.user_id);
|
||||
if (permCheck.permission !== 'admin') {
|
||||
return new Response(JSON.stringify({ error: 'Admin access required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json() as {
|
||||
permission: PermissionLevel;
|
||||
label?: string;
|
||||
expiresIn?: number; // seconds from now
|
||||
maxUses?: number;
|
||||
};
|
||||
|
||||
if (!body.permission || !['view', 'edit', 'admin'].includes(body.permission)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid permission level' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure board exists
|
||||
await ensureBoardExists(db, boardId, deviceKey.user_id);
|
||||
|
||||
const token = generateAccessToken();
|
||||
const id = generateUUID();
|
||||
|
||||
// Calculate expiration
|
||||
let expiresAt: string | null = null;
|
||||
if (body.expiresIn) {
|
||||
const expDate = new Date(Date.now() + body.expiresIn * 1000);
|
||||
expiresAt = expDate.toISOString();
|
||||
}
|
||||
|
||||
await db.prepare(`
|
||||
INSERT INTO board_access_tokens (id, board_id, token, permission, created_by, expires_at, max_uses, label, use_count, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 1)
|
||||
`).bind(
|
||||
id,
|
||||
boardId,
|
||||
token,
|
||||
body.permission,
|
||||
deviceKey.user_id,
|
||||
expiresAt,
|
||||
body.maxUses || null,
|
||||
body.label || null
|
||||
).run();
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
token,
|
||||
id,
|
||||
permission: body.permission,
|
||||
expiresAt,
|
||||
maxUses: body.maxUses || null,
|
||||
label: body.label || null
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Create access token error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /boards/:boardId/access-tokens
|
||||
* List all access tokens for a board (admin only)
|
||||
*/
|
||||
export async function handleListAccessTokens(
|
||||
boardId: string,
|
||||
request: Request,
|
||||
env: Environment
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return new Response(JSON.stringify({ error: 'Database not configured' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||
if (!publicKey) {
|
||||
return new Response(JSON.stringify({ error: 'Authentication required' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const deviceKey = await db.prepare(
|
||||
'SELECT user_id FROM device_keys WHERE public_key = ?'
|
||||
).bind(publicKey).first<{ user_id: string }>();
|
||||
|
||||
if (!deviceKey) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid credentials' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const permCheck = await getEffectivePermission(db, boardId, deviceKey.user_id);
|
||||
if (permCheck.permission !== 'admin') {
|
||||
return new Response(JSON.stringify({ error: 'Admin access required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const tokens = await db.prepare(`
|
||||
SELECT id, board_id, permission, created_at, expires_at, max_uses, use_count, is_active, label
|
||||
FROM board_access_tokens
|
||||
WHERE board_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`).bind(boardId).all<Omit<BoardAccessToken, 'token' | 'created_by'>>();
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
tokens: tokens.results || []
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('List access tokens error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /boards/:boardId/access-tokens/:tokenId
|
||||
* Revoke an access token (admin only)
|
||||
*/
|
||||
export async function handleRevokeAccessToken(
|
||||
boardId: string,
|
||||
tokenId: string,
|
||||
request: Request,
|
||||
env: Environment
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return new Response(JSON.stringify({ error: 'Database not configured' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||
if (!publicKey) {
|
||||
return new Response(JSON.stringify({ error: 'Authentication required' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const deviceKey = await db.prepare(
|
||||
'SELECT user_id FROM device_keys WHERE public_key = ?'
|
||||
).bind(publicKey).first<{ user_id: string }>();
|
||||
|
||||
if (!deviceKey) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid credentials' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const permCheck = await getEffectivePermission(db, boardId, deviceKey.user_id);
|
||||
if (permCheck.permission !== 'admin') {
|
||||
return new Response(JSON.stringify({ error: 'Admin access required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Deactivate the token (soft delete)
|
||||
await db.prepare(`
|
||||
UPDATE board_access_tokens SET is_active = 0 WHERE id = ? AND board_id = ?
|
||||
`).bind(tokenId, boardId).run();
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Access token revoked'
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Revoke access token error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ function generateUUID(): string {
|
|||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Send email via SendGrid
|
||||
// Send email via Resend
|
||||
async function sendEmail(
|
||||
env: Environment,
|
||||
to: string,
|
||||
|
|
@ -20,24 +20,33 @@ async function sendEmail(
|
|||
htmlContent: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
|
||||
if (!env.RESEND_API_KEY) {
|
||||
console.error('RESEND_API_KEY not configured');
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.SENDGRID_API_KEY}`,
|
||||
'Authorization': `Bearer ${env.RESEND_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
personalizations: [{ to: [{ email: to }] }],
|
||||
from: { email: env.CRYPTID_EMAIL_FROM || 'noreply@jeffemmett.com', name: 'CryptID' },
|
||||
from: env.CRYPTID_EMAIL_FROM || 'CryptID <noreply@jeffemmett.com>',
|
||||
to: [to],
|
||||
subject,
|
||||
content: [{ type: 'text/html', value: htmlContent }],
|
||||
html: htmlContent,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('SendGrid error:', await response.text());
|
||||
const errorText = await response.text();
|
||||
console.error('Resend error:', errorText);
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await response.json() as { id?: string };
|
||||
console.log('Email sent successfully, id:', result.id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Email send error:', error);
|
||||
|
|
@ -448,6 +457,174 @@ export async function handleLinkDevice(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a backup email to set up account on another device
|
||||
* POST /api/auth/send-backup-email
|
||||
* Body: { email, username }
|
||||
*
|
||||
* This is called during registration when the user provides an email.
|
||||
* It sends an email with a link to set up their account on another device.
|
||||
*/
|
||||
export async function handleSendBackupEmail(
|
||||
request: Request,
|
||||
env: Environment
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const body = await request.json() as {
|
||||
email: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
const { email, username } = body;
|
||||
|
||||
if (!email || !username) {
|
||||
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid email format' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
// If no DB, still try to send email (graceful degradation)
|
||||
console.log('No database configured, attempting to send email directly');
|
||||
}
|
||||
|
||||
// If DB exists, create/update user record
|
||||
if (db) {
|
||||
// Check if user already exists
|
||||
const existingUser = await db.prepare(
|
||||
'SELECT * FROM users WHERE cryptid_username = ?'
|
||||
).bind(username).first<User>();
|
||||
|
||||
if (existingUser) {
|
||||
// Update email if user exists
|
||||
await db.prepare(
|
||||
"UPDATE users SET email = ?, updated_at = datetime('now') WHERE cryptid_username = ?"
|
||||
).bind(email, username).run();
|
||||
} else {
|
||||
// Create new user record
|
||||
const userId = crypto.randomUUID();
|
||||
await db.prepare(
|
||||
'INSERT INTO users (id, email, cryptid_username, email_verified) VALUES (?, ?, ?, 0)'
|
||||
).bind(userId, email, username).run();
|
||||
}
|
||||
}
|
||||
|
||||
// Send the backup email
|
||||
const appUrl = env.APP_URL || 'https://jeffemmett.com';
|
||||
const setupUrl = `${appUrl}/setup-device?username=${encodeURIComponent(username)}`;
|
||||
|
||||
const emailSent = await sendEmail(
|
||||
env,
|
||||
email,
|
||||
`Set up CryptID "${username}" on another device`,
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { text-align: center; margin-bottom: 30px; }
|
||||
.icon { font-size: 48px; margin-bottom: 10px; }
|
||||
h1 { color: #8b5cf6; margin: 0 0 10px 0; }
|
||||
.card { background: #f9fafb; border-radius: 12px; padding: 24px; margin: 20px 0; }
|
||||
.step { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 16px; }
|
||||
.step-number { background: #8b5cf6; color: white; width: 24px; height: 24px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 600; flex-shrink: 0; }
|
||||
.button { display: inline-block; padding: 14px 28px; background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%); color: white; text-decoration: none; border-radius: 10px; font-weight: 600; margin: 20px 0; }
|
||||
.footer { color: #666; font-size: 12px; text-align: center; margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; }
|
||||
.warning { background: #fef3c7; border-radius: 8px; padding: 12px 16px; margin: 20px 0; color: #92400e; font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="icon">🔐</div>
|
||||
<h1>Welcome to CryptID</h1>
|
||||
<p>Your passwordless account is ready!</p>
|
||||
</div>
|
||||
|
||||
<p>Hi <strong>${username}</strong>,</p>
|
||||
|
||||
<p>Your CryptID account has been created on your current device. To access your account from another device (like your phone), follow these steps:</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="step">
|
||||
<span class="step-number">1</span>
|
||||
<div>
|
||||
<strong>Open the link below on your other device</strong>
|
||||
<p style="margin: 4px 0 0 0; color: #666; font-size: 14px;">Use your phone, tablet, or another computer</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<span class="step-number">2</span>
|
||||
<div>
|
||||
<strong>A new cryptographic key will be generated</strong>
|
||||
<p style="margin: 4px 0 0 0; color: #666; font-size: 14px;">This key is unique to that device and stored securely in the browser</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<span class="step-number">3</span>
|
||||
<div>
|
||||
<strong>You're set! Both devices can now access your account</strong>
|
||||
<p style="margin: 4px 0 0 0; color: #666; font-size: 14px;">No passwords to remember - your browser handles authentication</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="${setupUrl}" class="button">Set Up on Another Device</a>
|
||||
</div>
|
||||
|
||||
<div class="warning">
|
||||
<strong>⚠️ Security Note:</strong> Only open this link on devices you own. Each device that accesses your account stores a unique cryptographic key.
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>This email was sent because you created a CryptID account and opted for multi-device backup.</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
<p style="margin-top: 16px;">CryptID by <a href="${appUrl}" style="color: #8b5cf6;">jeffemmett.com</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
);
|
||||
|
||||
if (emailSent) {
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Backup email sent successfully',
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} else {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to send email',
|
||||
message: 'Please check your email address and try again',
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Send backup email error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a public key is linked to an account
|
||||
* POST /auth/lookup
|
||||
|
|
|
|||
|
|
@ -88,6 +88,29 @@ CREATE INDEX IF NOT EXISTS idx_board_perms_board ON board_permissions(board_id);
|
|||
CREATE INDEX IF NOT EXISTS idx_board_perms_user ON board_permissions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_board_perms_board_user ON board_permissions(board_id, user_id);
|
||||
|
||||
-- Access tokens for shareable links with specific permission levels
|
||||
-- Anonymous users can use these tokens to get edit/admin access without authentication
|
||||
CREATE TABLE IF NOT EXISTS board_access_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
board_id TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE, -- Random hex token (64 chars)
|
||||
permission TEXT NOT NULL CHECK (permission IN ('view', 'edit', 'admin')),
|
||||
created_by TEXT NOT NULL, -- User ID who created the token
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
expires_at TEXT, -- NULL = never expires
|
||||
max_uses INTEGER, -- NULL = unlimited
|
||||
use_count INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1, -- 1 = active, 0 = revoked
|
||||
label TEXT, -- Optional label for identification
|
||||
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Access token indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_access_tokens_board ON board_access_tokens(board_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_access_tokens_token ON board_access_tokens(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_access_tokens_active ON board_access_tokens(board_id, is_active);
|
||||
|
||||
-- =============================================================================
|
||||
-- User Networking / Social Graph System
|
||||
-- =============================================================================
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export interface Environment {
|
|||
DAILY_DOMAIN: string;
|
||||
// CryptID auth bindings
|
||||
CRYPTID_DB?: D1Database;
|
||||
SENDGRID_API_KEY?: string;
|
||||
RESEND_API_KEY?: string;
|
||||
CRYPTID_EMAIL_FROM?: string;
|
||||
APP_URL?: string;
|
||||
}
|
||||
|
|
@ -95,6 +95,25 @@ export interface PermissionCheckResult {
|
|||
permission: PermissionLevel;
|
||||
isOwner: boolean;
|
||||
boardExists: boolean;
|
||||
grantedByToken?: boolean; // True if permission was granted via access token
|
||||
}
|
||||
|
||||
/**
|
||||
* Access token for sharing boards with specific permissions
|
||||
* Stored in board_access_tokens table
|
||||
*/
|
||||
export interface BoardAccessToken {
|
||||
id: string;
|
||||
board_id: string;
|
||||
token: string; // Random token string
|
||||
permission: PermissionLevel;
|
||||
created_by: string; // User ID who created the token
|
||||
created_at: string;
|
||||
expires_at: string | null; // NULL = never expires
|
||||
max_uses: number | null; // NULL = unlimited
|
||||
use_count: number;
|
||||
is_active: number; // SQLite boolean (0 or 1)
|
||||
label: string | null; // Optional label for the token
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -23,7 +23,13 @@ import {
|
|||
handleGrantPermission,
|
||||
handleRevokePermission,
|
||||
handleUpdateBoard,
|
||||
handleCreateAccessToken,
|
||||
handleListAccessTokens,
|
||||
handleRevokeAccessToken,
|
||||
} from "./boardPermissions"
|
||||
import {
|
||||
handleSendBackupEmail,
|
||||
} from "./cryptidAuth"
|
||||
|
||||
// make sure our sync durable objects are made available to cloudflare
|
||||
export { AutomergeDurableObject } from "./AutomergeDurableObject"
|
||||
|
|
@ -826,6 +832,13 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|||
}
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// CryptID Auth API
|
||||
// =============================================================================
|
||||
|
||||
// Send backup email for multi-device setup
|
||||
.post("/api/auth/send-backup-email", handleSendBackupEmail)
|
||||
|
||||
// =============================================================================
|
||||
// User Networking / Social Graph API
|
||||
// =============================================================================
|
||||
|
|
@ -877,6 +890,19 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|||
.patch("/boards/:boardId", (req, env) =>
|
||||
handleUpdateBoard(req.params.boardId, req, env))
|
||||
|
||||
// Access token endpoints for shareable links
|
||||
// Create a new access token (admin only)
|
||||
.post("/boards/:boardId/access-tokens", (req, env) =>
|
||||
handleCreateAccessToken(req.params.boardId, req, env))
|
||||
|
||||
// List all access tokens for a board (admin only)
|
||||
.get("/boards/:boardId/access-tokens", (req, env) =>
|
||||
handleListAccessTokens(req.params.boardId, req, env))
|
||||
|
||||
// Revoke an access token (admin only)
|
||||
.delete("/boards/:boardId/access-tokens/:tokenId", (req, env) =>
|
||||
handleRevokeAccessToken(req.params.boardId, req.params.tokenId, req, env))
|
||||
|
||||
async function backupAllBoards(env: Environment) {
|
||||
try {
|
||||
// List all room files from TLDRAW_BUCKET
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ upstream_protocol = "https"
|
|||
[dev.miniflare]
|
||||
kv_persist = true
|
||||
r2_persist = true
|
||||
d1_persist = true
|
||||
durable_objects_persist = true
|
||||
|
||||
[durable_objects]
|
||||
|
|
@ -37,6 +38,10 @@ binding = 'BOARD_BACKUPS_BUCKET'
|
|||
bucket_name = 'board-backups-preview'
|
||||
preview_bucket_name = 'board-backups-preview'
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "CRYPTID_DB"
|
||||
database_name = "cryptid-auth-dev"
|
||||
database_id = "35fbe755-0e7c-4b9a-a454-34f945e5f7cc"
|
||||
|
||||
[observability]
|
||||
enabled = true
|
||||
|
|
|
|||
Loading…
Reference in New Issue