feat: redesign top-right UI, fix Map interactions and schema validation
UI Changes: - Add CryptIDDropdown component with Google integration under Integrations - Remove user presence avatars (moved to network graph) - New top-right layout: CryptID -> Star -> Gear dropdown -> Question mark - Settings gear shows dropdown with dark mode toggle + All Settings link - Network graph label changed to "Social Network" - Network graph shows for all users including anonymous - Solo users see themselves as a lone node Map Shape Fixes: - Fix stale closure bug: tool clicks now work using activeToolRef - Fix wheel scroll: native event listener prevents tldraw capture - Add pointerEvents: 'auto' to map container for proper mouse interaction Bug Fix: - Add Map shape sanitization in AutomergeToTLStore for pinnedToView/isMinimized - Prevents "Expected boolean, got undefined" errors on old Map data 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
fc43e8e803
commit
8123f0fadd
|
|
@ -765,6 +765,79 @@ export function sanitizeRecord(record: any): TLRecord {
|
||||||
console.log(`🔧 Sanitized Multmux shape ${sanitized.id} props:`, JSON.stringify(sanitized.props))
|
console.log(`🔧 Sanitized Multmux shape ${sanitized.id} props:`, JSON.stringify(sanitized.props))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Sanitize Map shapes - ensure all required props have defaults
|
||||||
|
// Old shapes may be missing pinnedToView, isMinimized, or other newer properties
|
||||||
|
if (sanitized.type === 'Map') {
|
||||||
|
// Ensure boolean props have proper defaults (old data may have undefined)
|
||||||
|
if (typeof sanitized.props.pinnedToView !== 'boolean') {
|
||||||
|
sanitized.props.pinnedToView = false
|
||||||
|
}
|
||||||
|
if (typeof sanitized.props.isMinimized !== 'boolean') {
|
||||||
|
sanitized.props.isMinimized = false
|
||||||
|
}
|
||||||
|
if (typeof sanitized.props.showSidebar !== 'boolean') {
|
||||||
|
sanitized.props.showSidebar = true
|
||||||
|
}
|
||||||
|
if (typeof sanitized.props.interactive !== 'boolean') {
|
||||||
|
sanitized.props.interactive = true
|
||||||
|
}
|
||||||
|
if (typeof sanitized.props.showGPS !== 'boolean') {
|
||||||
|
sanitized.props.showGPS = false
|
||||||
|
}
|
||||||
|
if (typeof sanitized.props.showSearch !== 'boolean') {
|
||||||
|
sanitized.props.showSearch = false
|
||||||
|
}
|
||||||
|
if (typeof sanitized.props.showDirections !== 'boolean') {
|
||||||
|
sanitized.props.showDirections = false
|
||||||
|
}
|
||||||
|
if (typeof sanitized.props.sharingLocation !== 'boolean') {
|
||||||
|
sanitized.props.sharingLocation = false
|
||||||
|
}
|
||||||
|
// Ensure array props exist
|
||||||
|
if (!Array.isArray(sanitized.props.annotations)) {
|
||||||
|
sanitized.props.annotations = []
|
||||||
|
}
|
||||||
|
if (!Array.isArray(sanitized.props.waypoints)) {
|
||||||
|
sanitized.props.waypoints = []
|
||||||
|
}
|
||||||
|
if (!Array.isArray(sanitized.props.collaborators)) {
|
||||||
|
sanitized.props.collaborators = []
|
||||||
|
}
|
||||||
|
if (!Array.isArray(sanitized.props.gpsUsers)) {
|
||||||
|
sanitized.props.gpsUsers = []
|
||||||
|
}
|
||||||
|
if (!Array.isArray(sanitized.props.tags)) {
|
||||||
|
sanitized.props.tags = ['map']
|
||||||
|
}
|
||||||
|
// Ensure string props exist
|
||||||
|
if (typeof sanitized.props.styleKey !== 'string') {
|
||||||
|
sanitized.props.styleKey = 'voyager'
|
||||||
|
}
|
||||||
|
if (typeof sanitized.props.title !== 'string') {
|
||||||
|
sanitized.props.title = 'Collaborative Map'
|
||||||
|
}
|
||||||
|
if (typeof sanitized.props.description !== 'string') {
|
||||||
|
sanitized.props.description = ''
|
||||||
|
}
|
||||||
|
// Ensure viewport exists with defaults
|
||||||
|
if (!sanitized.props.viewport || typeof sanitized.props.viewport !== 'object') {
|
||||||
|
sanitized.props.viewport = {
|
||||||
|
center: { lat: 40.7128, lng: -74.006 },
|
||||||
|
zoom: 12,
|
||||||
|
bearing: 0,
|
||||||
|
pitch: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ensure numeric props
|
||||||
|
if (typeof sanitized.props.w !== 'number' || isNaN(sanitized.props.w)) {
|
||||||
|
sanitized.props.w = 800
|
||||||
|
}
|
||||||
|
if (typeof sanitized.props.h !== 'number' || isNaN(sanitized.props.h)) {
|
||||||
|
sanitized.props.h = 550
|
||||||
|
}
|
||||||
|
console.log(`🔧 Sanitized Map shape ${sanitized.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
// CRITICAL: Infer type from properties BEFORE defaulting to 'geo'
|
// CRITICAL: Infer type from properties BEFORE defaulting to 'geo'
|
||||||
// This ensures arrows and other shapes are properly recognized
|
// This ensures arrows and other shapes are properly recognized
|
||||||
if (!sanitized.type || typeof sanitized.type !== 'string') {
|
if (!sanitized.type || typeof sanitized.type !== 'string') {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,417 @@
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import CryptID from './CryptID';
|
||||||
|
import { GoogleDataService, type GoogleService } from '../../lib/google';
|
||||||
|
import { GoogleExportBrowser } from '../GoogleExportBrowser';
|
||||||
|
|
||||||
|
interface CryptIDDropdownProps {
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CryptID dropdown component for the top-right corner.
|
||||||
|
* Shows logged-in user with dropdown containing account info and integrations.
|
||||||
|
*/
|
||||||
|
const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false }) => {
|
||||||
|
const { session, logout } = useAuth();
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
|
const [showCryptIDModal, setShowCryptIDModal] = useState(false);
|
||||||
|
const [showGoogleBrowser, setShowGoogleBrowser] = useState(false);
|
||||||
|
const [googleConnected, setGoogleConnected] = useState(false);
|
||||||
|
const [googleLoading, setGoogleLoading] = useState(false);
|
||||||
|
const [googleCounts, setGoogleCounts] = useState<Record<GoogleService, number>>({
|
||||||
|
gmail: 0,
|
||||||
|
drive: 0,
|
||||||
|
photos: 0,
|
||||||
|
calendar: 0,
|
||||||
|
});
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Check Google connection on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const checkGoogleStatus = async () => {
|
||||||
|
try {
|
||||||
|
const service = GoogleDataService.getInstance();
|
||||||
|
const isAuthed = await service.isAuthenticated();
|
||||||
|
setGoogleConnected(isAuthed);
|
||||||
|
if (isAuthed) {
|
||||||
|
const counts = await service.getStoredCounts();
|
||||||
|
setGoogleCounts(counts);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to check Google status:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkGoogleStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setShowDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (showDropdown) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [showDropdown]);
|
||||||
|
|
||||||
|
const handleGoogleConnect = async () => {
|
||||||
|
setGoogleLoading(true);
|
||||||
|
try {
|
||||||
|
const service = GoogleDataService.getInstance();
|
||||||
|
await service.authenticate(['gmail', 'drive', 'photos', 'calendar']);
|
||||||
|
setGoogleConnected(true);
|
||||||
|
const counts = await service.getStoredCounts();
|
||||||
|
setGoogleCounts(counts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Google auth failed:', error);
|
||||||
|
} finally {
|
||||||
|
setGoogleLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleDisconnect = async () => {
|
||||||
|
try {
|
||||||
|
const service = GoogleDataService.getInstance();
|
||||||
|
await service.signOut();
|
||||||
|
setGoogleConnected(false);
|
||||||
|
setGoogleCounts({ gmail: 0, drive: 0, photos: 0, calendar: 0 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Google disconnect failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToCanvas = async (items: any[], position: { x: number; y: number }) => {
|
||||||
|
// Emit event for canvas to handle
|
||||||
|
window.dispatchEvent(new CustomEvent('add-google-items-to-canvas', {
|
||||||
|
detail: { items, position }
|
||||||
|
}));
|
||||||
|
setShowGoogleBrowser(false);
|
||||||
|
setShowDropdown(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalGoogleItems = Object.values(googleCounts).reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
// Get initials for avatar
|
||||||
|
const getInitials = (name: string) => {
|
||||||
|
return name.charAt(0).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
// If showing CryptID modal
|
||||||
|
if (showCryptIDModal) {
|
||||||
|
return (
|
||||||
|
<div className="cryptid-modal-overlay">
|
||||||
|
<div className="cryptid-modal">
|
||||||
|
<CryptID
|
||||||
|
onSuccess={() => setShowCryptIDModal(false)}
|
||||||
|
onCancel={() => setShowCryptIDModal(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={dropdownRef} className="cryptid-dropdown" style={{ position: 'relative' }}>
|
||||||
|
{/* Trigger button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDropdown(!showDropdown)}
|
||||||
|
className="cryptid-trigger"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
background: showDropdown ? 'var(--color-muted-2)' : 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--color-text-1)',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
title={session.authed ? session.username : 'Sign in with CryptID'}
|
||||||
|
>
|
||||||
|
{session.authed ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getInitials(session.username)}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 500, maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{session.username}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 500 }}>Sign In</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="6 9 12 15 18 9"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown menu */}
|
||||||
|
{showDropdown && (
|
||||||
|
<div
|
||||||
|
className="cryptid-dropdown-menu"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 'calc(100% + 8px)',
|
||||||
|
right: 0,
|
||||||
|
minWidth: '260px',
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.authed ? (
|
||||||
|
<>
|
||||||
|
{/* Account section */}
|
||||||
|
<div style={{ padding: '16px', borderBottom: '1px solid var(--color-panel-contrast)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getInitials(session.username)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
|
||||||
|
{session.username}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--color-text-3)', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<span style={{ color: '#22c55e' }}>🔒</span> CryptID secured
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Integrations section */}
|
||||||
|
<div style={{ padding: '8px 0' }}>
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
}}>
|
||||||
|
Integrations
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Google Workspace */}
|
||||||
|
<div style={{ padding: '8px 16px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: 'linear-gradient(135deg, #4285F4, #34A853, #FBBC04, #EA4335)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'white',
|
||||||
|
}}>
|
||||||
|
G
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text)' }}>
|
||||||
|
Google Workspace
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>
|
||||||
|
{googleConnected ? `${totalGoogleItems} items imported` : 'Not connected'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{googleConnected && (
|
||||||
|
<span style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: '#22c55e',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
{googleConnected ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowGoogleBrowser(true);
|
||||||
|
setShowDropdown(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: '#4285F4',
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Browse Data
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleGoogleDisconnect}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid var(--color-panel-contrast)',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleGoogleConnect}
|
||||||
|
disabled={googleLoading}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid var(--color-panel-contrast)',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
cursor: googleLoading ? 'wait' : 'pointer',
|
||||||
|
opacity: googleLoading ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{googleLoading ? 'Connecting...' : 'Connect Google'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sign out */}
|
||||||
|
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-panel-contrast)' }}>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await logout();
|
||||||
|
setShowDropdown(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--color-muted-2)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||||
|
<polyline points="16 17 21 12 16 7"></polyline>
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '16px' }}>
|
||||||
|
<div style={{ marginBottom: '12px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)', marginBottom: '4px' }}>
|
||||||
|
Sign in with CryptID
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', lineHeight: 1.4 }}>
|
||||||
|
Create a username to edit boards and sync your data across devices.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowCryptIDModal(true);
|
||||||
|
setShowDropdown(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 16px',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create or Sign In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Google Export Browser Modal */}
|
||||||
|
{showGoogleBrowser && (
|
||||||
|
<GoogleExportBrowser
|
||||||
|
isOpen={showGoogleBrowser}
|
||||||
|
onClose={() => setShowGoogleBrowser(false)}
|
||||||
|
onAddToCanvas={handleAddToCanvas}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CryptIDDropdown;
|
||||||
|
|
@ -379,7 +379,7 @@ export function NetworkGraphMinimap({
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<div style={styles.panel}>
|
<div style={styles.panel}>
|
||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
<h3 style={styles.title}>Network</h3>
|
<h3 style={styles.title}>Social Network</h3>
|
||||||
<div style={styles.headerButtons}>
|
<div style={styles.headerButtons}>
|
||||||
<button
|
<button
|
||||||
style={styles.iconButton}
|
style={styles.iconButton}
|
||||||
|
|
|
||||||
|
|
@ -111,11 +111,6 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
|
||||||
}
|
}
|
||||||
}, [onExpand]);
|
}, [onExpand]);
|
||||||
|
|
||||||
// Don't render if not authenticated
|
|
||||||
if (!session.authed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading state briefly
|
// Show loading state briefly
|
||||||
if (isLoading && nodes.length === 0) {
|
if (isLoading && nodes.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -103,12 +103,29 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
|
||||||
|
|
||||||
// Fetch the network graph
|
// Fetch the network graph
|
||||||
const fetchGraph = useCallback(async (skipCache = false) => {
|
const fetchGraph = useCallback(async (skipCache = false) => {
|
||||||
|
// For unauthenticated users, just show room participants without network connections
|
||||||
if (!session.authed || !session.username) {
|
if (!session.authed || !session.username) {
|
||||||
setState(prev => ({
|
// Create nodes from room participants for anonymous users
|
||||||
...prev,
|
const anonymousNodes: GraphNode[] = roomParticipants.map(participant => ({
|
||||||
isLoading: false,
|
id: participant.id,
|
||||||
error: 'Not authenticated',
|
username: participant.username,
|
||||||
|
displayName: participant.username,
|
||||||
|
avatarColor: participant.color,
|
||||||
|
isInRoom: true,
|
||||||
|
roomPresenceColor: participant.color,
|
||||||
|
isCurrentUser: participant.id === roomParticipants[0]?.id, // First participant is current user
|
||||||
|
isAnonymous: true,
|
||||||
|
trustLevelTo: undefined,
|
||||||
|
trustLevelFrom: undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
setState({
|
||||||
|
nodes: anonymousNodes,
|
||||||
|
edges: [],
|
||||||
|
myConnections: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,7 +213,7 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
|
||||||
error: (error as Error).message,
|
error: (error as Error).message,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [session.authed, session.username, participantIds, participantColorMap, useCache]);
|
}, [session.authed, session.username, participantIds, participantColorMap, useCache, roomParticipants]);
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -326,7 +326,13 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
|
||||||
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const [activeTool, setActiveTool] = useState<'cursor' | 'marker' | 'line' | 'area' | 'eraser'>('cursor');
|
const [activeTool, setActiveTool] = useState<'cursor' | 'marker' | 'line' | 'area' | 'eraser'>('cursor');
|
||||||
|
const activeToolRef = useRef(activeTool); // Ref to track current tool in event handlers
|
||||||
const [selectedColor, setSelectedColor] = useState(COLORS[4]);
|
const [selectedColor, setSelectedColor] = useState(COLORS[4]);
|
||||||
|
|
||||||
|
// Keep ref in sync with state
|
||||||
|
useEffect(() => {
|
||||||
|
activeToolRef.current = activeTool;
|
||||||
|
}, [activeTool]);
|
||||||
const [showColorPicker, setShowColorPicker] = useState(false);
|
const [showColorPicker, setShowColorPicker] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||||
|
|
@ -396,10 +402,14 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
|
||||||
const handleClick = (e: maplibregl.MapMouseEvent) => {
|
const handleClick = (e: maplibregl.MapMouseEvent) => {
|
||||||
if (!isMountedRef.current) return;
|
if (!isMountedRef.current) return;
|
||||||
const coord = { lat: e.lngLat.lat, lng: e.lngLat.lng };
|
const coord = { lat: e.lngLat.lat, lng: e.lngLat.lng };
|
||||||
|
const currentTool = activeToolRef.current;
|
||||||
|
|
||||||
if (activeTool === 'marker') {
|
console.log('Map click with tool:', currentTool, 'at', coord);
|
||||||
|
|
||||||
|
if (currentTool === 'marker') {
|
||||||
addAnnotation('marker', [coord]);
|
addAnnotation('marker', [coord]);
|
||||||
}
|
}
|
||||||
|
// TODO: Implement line and area drawing
|
||||||
};
|
};
|
||||||
|
|
||||||
map.on('load', handleLoad);
|
map.on('load', handleLoad);
|
||||||
|
|
@ -793,21 +803,25 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle wheel events on map container - forward delta to map for zooming
|
// Handle wheel events on map container - attach native listener for proper capture
|
||||||
const handleMapWheel = useCallback((e: React.WheelEvent) => {
|
useEffect(() => {
|
||||||
e.stopPropagation();
|
const mapContainer = containerRef.current?.parentElement;
|
||||||
e.preventDefault();
|
if (!mapContainer) return;
|
||||||
// Forward wheel event to the map for zooming
|
|
||||||
if (mapRef.current) {
|
const handleWheel = (e: WheelEvent) => {
|
||||||
const map = mapRef.current;
|
// Stop propagation to prevent tldraw from capturing the wheel event
|
||||||
const delta = e.deltaY > 0 ? -1 : 1;
|
e.stopPropagation();
|
||||||
const currentZoom = map.getZoom();
|
// Let maplibre handle the wheel event natively for zooming
|
||||||
map.easeTo({
|
// Don't prevent default - let the map's scrollZoom handle it
|
||||||
zoom: currentZoom + delta * 0.5,
|
};
|
||||||
duration: 150,
|
|
||||||
});
|
// Capture wheel events before they bubble up to tldraw
|
||||||
}
|
mapContainer.addEventListener('wheel', handleWheel, { passive: true });
|
||||||
}, []);
|
|
||||||
|
return () => {
|
||||||
|
mapContainer.removeEventListener('wheel', handleWheel);
|
||||||
|
};
|
||||||
|
}, [isLoaded]);
|
||||||
|
|
||||||
// Close handler for StandardizedToolWrapper
|
// Close handler for StandardizedToolWrapper
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
|
|
@ -1102,10 +1116,10 @@ function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor:
|
||||||
|
|
||||||
{/* Map Container */}
|
{/* Map Container */}
|
||||||
<div
|
<div
|
||||||
style={{ flex: 1, position: 'relative' }}
|
style={{ flex: 1, position: 'relative', pointerEvents: 'auto' }}
|
||||||
onWheel={handleMapWheel}
|
onPointerDown={stopPropagation}
|
||||||
>
|
>
|
||||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
<div ref={containerRef} style={{ width: '100%', height: '100%', pointerEvents: 'auto' }} />
|
||||||
|
|
||||||
{/* Sidebar Toggle */}
|
{/* Sidebar Toggle */}
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ import { FocusLockIndicator } from "./FocusLockIndicator"
|
||||||
import { MycelialIntelligenceBar } from "./MycelialIntelligenceBar"
|
import { MycelialIntelligenceBar } from "./MycelialIntelligenceBar"
|
||||||
import { CommandPalette } from "./CommandPalette"
|
import { CommandPalette } from "./CommandPalette"
|
||||||
import { UserSettingsModal } from "./UserSettingsModal"
|
import { UserSettingsModal } from "./UserSettingsModal"
|
||||||
import { GoogleExportBrowser } from "../components/GoogleExportBrowser"
|
|
||||||
import { NetworkGraphPanel } from "../components/networking"
|
import { NetworkGraphPanel } from "../components/networking"
|
||||||
|
import CryptIDDropdown from "../components/auth/CryptIDDropdown"
|
||||||
|
import StarBoardButton from "../components/StarBoardButton"
|
||||||
import {
|
import {
|
||||||
DefaultKeyboardShortcutsDialog,
|
DefaultKeyboardShortcutsDialog,
|
||||||
DefaultKeyboardShortcutsDialogContent,
|
DefaultKeyboardShortcutsDialogContent,
|
||||||
|
|
@ -15,397 +16,28 @@ import {
|
||||||
TldrawUiMenuItem,
|
TldrawUiMenuItem,
|
||||||
useTools,
|
useTools,
|
||||||
useActions,
|
useActions,
|
||||||
useEditor,
|
|
||||||
useValue,
|
|
||||||
} from "tldraw"
|
} from "tldraw"
|
||||||
import { SlidesPanel } from "@/slides/SlidesPanel"
|
import { SlidesPanel } from "@/slides/SlidesPanel"
|
||||||
|
|
||||||
// Custom People Menu component for showing connected users and integrations
|
// Custom SharePanel with layout: CryptID -> Star -> Gear -> Question mark
|
||||||
function CustomPeopleMenu() {
|
|
||||||
const editor = useEditor()
|
|
||||||
const [showDropdown, setShowDropdown] = React.useState(false)
|
|
||||||
const [showGoogleBrowser, setShowGoogleBrowser] = React.useState(false)
|
|
||||||
const [googleConnected, setGoogleConnected] = React.useState(false)
|
|
||||||
const [googleLoading, setGoogleLoading] = React.useState(false)
|
|
||||||
|
|
||||||
// Detect dark mode
|
|
||||||
const isDarkMode = typeof document !== 'undefined' &&
|
|
||||||
document.documentElement.classList.contains('dark')
|
|
||||||
|
|
||||||
// Get current user info
|
|
||||||
const myUserColor = useValue('myColor', () => editor.user.getColor(), [editor])
|
|
||||||
const myUserName = useValue('myName', () => editor.user.getName() || 'You', [editor])
|
|
||||||
|
|
||||||
// Check Google connection on mount
|
|
||||||
React.useEffect(() => {
|
|
||||||
const checkGoogleStatus = async () => {
|
|
||||||
try {
|
|
||||||
const { GoogleDataService } = await import('../lib/google')
|
|
||||||
const service = GoogleDataService.getInstance()
|
|
||||||
const isAuthed = await service.isAuthenticated()
|
|
||||||
setGoogleConnected(isAuthed)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to check Google status:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checkGoogleStatus()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleGoogleConnect = async () => {
|
|
||||||
setGoogleLoading(true)
|
|
||||||
try {
|
|
||||||
const { GoogleDataService } = await import('../lib/google')
|
|
||||||
const service = GoogleDataService.getInstance()
|
|
||||||
await service.authenticate(['drive'])
|
|
||||||
setGoogleConnected(true)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Google auth failed:', error)
|
|
||||||
} finally {
|
|
||||||
setGoogleLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOpenGoogleBrowser = () => {
|
|
||||||
setShowDropdown(false)
|
|
||||||
setShowGoogleBrowser(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddToCanvas = async (items: any[], position: { x: number; y: number }) => {
|
|
||||||
try {
|
|
||||||
const { createGoogleItemProps } = await import('../shapes/GoogleItemShapeUtil')
|
|
||||||
|
|
||||||
// Create shapes for each selected item
|
|
||||||
items.forEach((item, index) => {
|
|
||||||
const props = createGoogleItemProps(item, 'local')
|
|
||||||
editor.createShape({
|
|
||||||
type: 'GoogleItem',
|
|
||||||
x: position.x + (index % 3) * 240,
|
|
||||||
y: position.y + Math.floor(index / 3) * 160,
|
|
||||||
props,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
setShowGoogleBrowser(false)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to add items to canvas:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all collaborators (other users in the session)
|
|
||||||
const collaborators = useValue('collaborators', () => editor.getCollaborators(), [editor])
|
|
||||||
|
|
||||||
const totalUsers = collaborators.length + 1
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="custom-people-menu" style={{ position: 'relative' }}>
|
|
||||||
{/* Clickable avatar stack */}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowDropdown(!showDropdown)}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
padding: 0,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
title="Click to see participants"
|
|
||||||
>
|
|
||||||
{/* Current user avatar */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '28px',
|
|
||||||
height: '28px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: myUserColor,
|
|
||||||
border: '2px solid white',
|
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: '10px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'white',
|
|
||||||
textShadow: '0 1px 2px rgba(0,0,0,0.3)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{myUserName.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Other users (stacked) */}
|
|
||||||
{collaborators.slice(0, 3).map((presence) => (
|
|
||||||
<div
|
|
||||||
key={presence.id}
|
|
||||||
style={{
|
|
||||||
width: '28px',
|
|
||||||
height: '28px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: presence.color,
|
|
||||||
border: '2px solid white',
|
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
|
||||||
marginLeft: '-10px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: '10px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'white',
|
|
||||||
textShadow: '0 1px 2px rgba(0,0,0,0.3)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(presence.userName || 'A').charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* User count badge if more than shown */}
|
|
||||||
{totalUsers > 1 && (
|
|
||||||
<span style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 500,
|
|
||||||
color: 'var(--color-text-1)',
|
|
||||||
marginLeft: '6px',
|
|
||||||
}}>
|
|
||||||
{totalUsers}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Dropdown with user names */}
|
|
||||||
{showDropdown && (
|
|
||||||
<div
|
|
||||||
className="people-dropdown"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 'calc(100% + 8px)',
|
|
||||||
right: 0,
|
|
||||||
minWidth: '180px',
|
|
||||||
background: 'var(--bg-color, #fff)',
|
|
||||||
border: '1px solid var(--border-color, #e1e4e8)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
|
||||||
zIndex: 100000,
|
|
||||||
padding: '8px 0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
padding: '6px 12px',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--tool-text)',
|
|
||||||
opacity: 0.7,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.5px',
|
|
||||||
}}>
|
|
||||||
Participants ({totalUsers})
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current user */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '10px',
|
|
||||||
padding: '8px 12px',
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: '24px',
|
|
||||||
height: '24px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: myUserColor,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: '10px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'white',
|
|
||||||
textShadow: '0 1px 2px rgba(0,0,0,0.3)',
|
|
||||||
}}>
|
|
||||||
{myUserName.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
color: 'var(--text-color)',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}>
|
|
||||||
{myUserName} (you)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Other users */}
|
|
||||||
{collaborators.map((presence) => (
|
|
||||||
<div
|
|
||||||
key={presence.id}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '10px',
|
|
||||||
padding: '8px 12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
width: '24px',
|
|
||||||
height: '24px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: presence.color,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: '10px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'white',
|
|
||||||
textShadow: '0 1px 2px rgba(0,0,0,0.3)',
|
|
||||||
}}>
|
|
||||||
{(presence.userName || 'A').charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
color: 'var(--text-color)',
|
|
||||||
}}>
|
|
||||||
{presence.userName || 'Anonymous'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Separator */}
|
|
||||||
<div style={{
|
|
||||||
height: '1px',
|
|
||||||
backgroundColor: 'var(--border-color, #e1e4e8)',
|
|
||||||
margin: '8px 0',
|
|
||||||
}} />
|
|
||||||
|
|
||||||
{/* Google Workspace Section */}
|
|
||||||
<div style={{
|
|
||||||
padding: '6px 12px',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--tool-text)',
|
|
||||||
opacity: 0.7,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.5px',
|
|
||||||
}}>
|
|
||||||
Integrations
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '10px',
|
|
||||||
padding: '8px 12px',
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: '24px',
|
|
||||||
height: '24px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
background: 'linear-gradient(135deg, #4285F4, #34A853, #FBBC04, #EA4335)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: '12px',
|
|
||||||
}}>
|
|
||||||
G
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
color: 'var(--text-color)',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}>
|
|
||||||
Google Workspace
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '11px',
|
|
||||||
color: 'var(--tool-text)',
|
|
||||||
opacity: 0.7,
|
|
||||||
}}>
|
|
||||||
{googleConnected ? 'Connected' : 'Not connected'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{googleConnected ? (
|
|
||||||
<span style={{
|
|
||||||
width: '8px',
|
|
||||||
height: '8px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: '#22c55e',
|
|
||||||
}} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Google action buttons */}
|
|
||||||
<div style={{
|
|
||||||
padding: '4px 12px 8px',
|
|
||||||
display: 'flex',
|
|
||||||
gap: '8px',
|
|
||||||
}}>
|
|
||||||
{!googleConnected ? (
|
|
||||||
<button
|
|
||||||
onClick={handleGoogleConnect}
|
|
||||||
disabled={googleLoading}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '6px 10px',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 500,
|
|
||||||
borderRadius: '6px',
|
|
||||||
border: '1px solid var(--border-color, #e1e4e8)',
|
|
||||||
backgroundColor: 'var(--bg-color, #fff)',
|
|
||||||
color: 'var(--text-color)',
|
|
||||||
cursor: googleLoading ? 'wait' : 'pointer',
|
|
||||||
opacity: googleLoading ? 0.7 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{googleLoading ? 'Connecting...' : 'Connect'}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={handleOpenGoogleBrowser}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '6px 10px',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 500,
|
|
||||||
borderRadius: '6px',
|
|
||||||
border: 'none',
|
|
||||||
backgroundColor: '#4285F4',
|
|
||||||
color: 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Browse Data
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Google Export Browser Modal */}
|
|
||||||
{showGoogleBrowser && (
|
|
||||||
<GoogleExportBrowser
|
|
||||||
isOpen={showGoogleBrowser}
|
|
||||||
onClose={() => setShowGoogleBrowser(false)}
|
|
||||||
onAddToCanvas={handleAddToCanvas}
|
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Click outside to close */}
|
|
||||||
{showDropdown && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 99999,
|
|
||||||
}}
|
|
||||||
onClick={() => setShowDropdown(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom SharePanel that shows people menu and help button
|
|
||||||
function CustomSharePanel() {
|
function CustomSharePanel() {
|
||||||
const tools = useTools()
|
const tools = useTools()
|
||||||
const actions = useActions()
|
const actions = useActions()
|
||||||
const [showShortcuts, setShowShortcuts] = React.useState(false)
|
const [showShortcuts, setShowShortcuts] = React.useState(false)
|
||||||
|
const [showSettings, setShowSettings] = React.useState(false)
|
||||||
|
const [showSettingsDropdown, setShowSettingsDropdown] = React.useState(false)
|
||||||
|
|
||||||
|
// Detect dark mode - use state to trigger re-render on change
|
||||||
|
const [isDarkMode, setIsDarkMode] = React.useState(
|
||||||
|
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleToggleDarkMode = () => {
|
||||||
|
const newIsDark = !document.documentElement.classList.contains('dark')
|
||||||
|
document.documentElement.classList.toggle('dark')
|
||||||
|
localStorage.setItem('theme', newIsDark ? 'dark' : 'light')
|
||||||
|
setIsDarkMode(newIsDark)
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to extract label string from tldraw label (can be string or {default, menu} object)
|
// Helper to extract label string from tldraw label (can be string or {default, menu} object)
|
||||||
const getLabelString = (label: any, fallback: string): string => {
|
const getLabelString = (label: any, fallback: string): string => {
|
||||||
|
|
@ -487,9 +119,159 @@ function CustomSharePanel() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tlui-share-zone" draggable={false} style={{ display: 'flex', alignItems: 'center', gap: '8px', position: 'relative' }}>
|
<div className="tlui-share-zone" draggable={false} style={{ display: 'flex', alignItems: 'center', gap: '8px', position: 'relative' }}>
|
||||||
{/* Help/Keyboard shortcuts button */}
|
{/* CryptID dropdown - leftmost */}
|
||||||
|
<CryptIDDropdown isDarkMode={isDarkMode} />
|
||||||
|
|
||||||
|
{/* Star board button */}
|
||||||
|
<StarBoardButton className="share-panel-btn" />
|
||||||
|
|
||||||
|
{/* Settings gear button with dropdown */}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettingsDropdown(!showSettingsDropdown)}
|
||||||
|
className="share-panel-btn"
|
||||||
|
style={{
|
||||||
|
background: showSettingsDropdown ? 'var(--color-muted-2)' : 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--color-text-1)',
|
||||||
|
opacity: showSettingsDropdown ? 1 : 0.7,
|
||||||
|
transition: 'opacity 0.15s, background 0.15s',
|
||||||
|
pointerEvents: 'all',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.opacity = '1'
|
||||||
|
e.currentTarget.style.background = 'var(--color-muted-2)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!showSettingsDropdown) {
|
||||||
|
e.currentTarget.style.opacity = '0.7'
|
||||||
|
e.currentTarget.style.background = 'none'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Settings dropdown */}
|
||||||
|
{showSettingsDropdown && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 99998,
|
||||||
|
}}
|
||||||
|
onClick={() => setShowSettingsDropdown(false)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 'calc(100% + 8px)',
|
||||||
|
right: 0,
|
||||||
|
minWidth: '200px',
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Dark mode toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleToggleDarkMode()
|
||||||
|
}}
|
||||||
|
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' }}>{isDarkMode ? '🌙' : '☀️'}</span>
|
||||||
|
<span>Appearance</span>
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: 'var(--color-muted-2)',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}>
|
||||||
|
{isDarkMode ? 'Dark' : 'Light'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '4px 0' }} />
|
||||||
|
|
||||||
|
{/* All settings */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowSettingsDropdown(false)
|
||||||
|
setShowSettings(true)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
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'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>All Settings...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Help/Keyboard shortcuts button - rightmost */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowShortcuts(!showShortcuts)}
|
onClick={() => setShowShortcuts(!showShortcuts)}
|
||||||
|
className="share-panel-btn"
|
||||||
style={{
|
style={{
|
||||||
background: showShortcuts ? 'var(--color-muted-2)' : 'none',
|
background: showShortcuts ? 'var(--color-muted-2)' : 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
|
|
@ -624,7 +406,14 @@ function CustomSharePanel() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CustomPeopleMenu />
|
{/* Settings Modal */}
|
||||||
|
{showSettings && (
|
||||||
|
<UserSettingsModal
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
onToggleDarkMode={handleToggleDarkMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue