1585 lines
69 KiB
TypeScript
1585 lines
69 KiB
TypeScript
import { TldrawUiMenuItem } from "tldraw"
|
||
import { DefaultToolbar, DefaultToolbarContent } from "tldraw"
|
||
import { useTools } from "tldraw"
|
||
import { useEditor } from "tldraw"
|
||
import { useState, useEffect, useRef, useMemo } from "react"
|
||
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 { ObsidianVaultBrowser } from "../components/ObsidianVaultBrowser"
|
||
import { HolonBrowser } from "../components/HolonBrowser"
|
||
import { ObsNoteShape } from "../shapes/ObsNoteShapeUtil"
|
||
import { createShapeId } from "tldraw"
|
||
import type { ObsidianObsNote } from "../lib/obsidianImporter"
|
||
import { HolonData } from "../lib/HoloSphereService"
|
||
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
|
||
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
||
import { getMyConnections, updateEdgeMetadata, createConnection, removeConnection, updateTrustLevel } from "../lib/networking/connectionService"
|
||
import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from "../lib/networking/types"
|
||
import { useValue } from "tldraw"
|
||
|
||
// AI tool model configurations for the dropdown
|
||
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' },
|
||
]
|
||
|
||
// Dark mode utilities
|
||
const getDarkMode = (): boolean => {
|
||
const stored = localStorage.getItem('darkMode')
|
||
if (stored !== null) {
|
||
return stored === 'true'
|
||
}
|
||
// Default to light mode instead of system preference
|
||
return false
|
||
}
|
||
|
||
const setDarkMode = (isDark: boolean) => {
|
||
localStorage.setItem('darkMode', String(isDark))
|
||
document.documentElement.classList.toggle('dark', isDark)
|
||
}
|
||
|
||
export function CustomToolbar() {
|
||
const editor = useEditor()
|
||
const tools = useTools()
|
||
const [isReady, setIsReady] = useState(false)
|
||
const [hasApiKey, setHasApiKey] = useState(false)
|
||
const { addDialog, removeDialog } = useDialogs()
|
||
|
||
const { session, setSession, clearSession } = useAuth()
|
||
const [showProfilePopup, setShowProfilePopup] = useState(false)
|
||
const [showVaultBrowser, setShowVaultBrowser] = useState(false)
|
||
const [showHolonBrowser, setShowHolonBrowser] = useState(false)
|
||
const [vaultBrowserMode, setVaultBrowserMode] = useState<'keyboard' | 'button'>('keyboard')
|
||
const [showFathomPanel, setShowFathomPanel] = useState(false)
|
||
const profilePopupRef = useRef<HTMLDivElement>(null)
|
||
const [isDarkMode, setIsDarkMode] = useState(getDarkMode())
|
||
|
||
// Dropdown section states
|
||
const [expandedSection, setExpandedSection] = useState<'none' | 'ai' | 'integrations' | 'connections'>('none')
|
||
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)
|
||
|
||
// Get collaborators from tldraw
|
||
const collaborators = useValue(
|
||
'collaborators',
|
||
() => editor.getCollaborators(),
|
||
[editor]
|
||
)
|
||
|
||
// 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 || 'Anonymous',
|
||
color: c.color || '#888888',
|
||
connectionStatus: connection
|
||
? connection.trustLevel
|
||
: 'unconnected' as const,
|
||
connectionId: connection?.id,
|
||
}
|
||
})
|
||
}, [collaborators, connections])
|
||
|
||
// Initialize dark mode on mount
|
||
useEffect(() => {
|
||
setDarkMode(isDarkMode)
|
||
}, [])
|
||
|
||
// Check Fathom API key status
|
||
useEffect(() => {
|
||
if (session.authed && session.username) {
|
||
setHasFathomApiKey(isFathomApiKeyConfigured(session.username))
|
||
}
|
||
}, [session.authed, session.username])
|
||
|
||
// Fetch connections when section is expanded
|
||
useEffect(() => {
|
||
if (expandedSection === 'connections' && session.authed) {
|
||
setConnectionsLoading(true)
|
||
getMyConnections()
|
||
.then(setConnections)
|
||
.catch(console.error)
|
||
.finally(() => setConnectionsLoading(false))
|
||
}
|
||
}, [expandedSection, session.authed])
|
||
|
||
// Handle saving edge metadata
|
||
const handleSaveMetadata = async (connectionId: string) => {
|
||
setSavingMetadata(true)
|
||
try {
|
||
await updateEdgeMetadata(connectionId, editingMetadata)
|
||
// Refresh connections to show updated metadata
|
||
const updated = await getMyConnections()
|
||
setConnections(updated)
|
||
setEditingConnectionId(null)
|
||
setEditingMetadata({})
|
||
} catch (error) {
|
||
console.error('Failed to save metadata:', error)
|
||
} finally {
|
||
setSavingMetadata(false)
|
||
}
|
||
}
|
||
|
||
// Handle connecting to a canvas user
|
||
const handleConnect = async (userId: string, trustLevel: TrustLevel = 'connected') => {
|
||
setConnectingUserId(userId)
|
||
try {
|
||
await createConnection(userId, trustLevel)
|
||
// Refresh connections
|
||
const updated = await getMyConnections()
|
||
setConnections(updated)
|
||
} catch (error) {
|
||
console.error('Failed to connect:', error)
|
||
} finally {
|
||
setConnectingUserId(null)
|
||
}
|
||
}
|
||
|
||
// Handle disconnecting from a user
|
||
const handleDisconnect = async (connectionId: string, userId: string) => {
|
||
setConnectingUserId(userId)
|
||
try {
|
||
await removeConnection(connectionId)
|
||
// Refresh connections
|
||
const updated = await getMyConnections()
|
||
setConnections(updated)
|
||
} catch (error) {
|
||
console.error('Failed to disconnect:', error)
|
||
} finally {
|
||
setConnectingUserId(null)
|
||
}
|
||
}
|
||
|
||
// Handle changing trust level
|
||
const handleChangeTrust = async (connectionId: string, userId: string, newLevel: TrustLevel) => {
|
||
setConnectingUserId(userId)
|
||
try {
|
||
await updateTrustLevel(connectionId, newLevel)
|
||
// Refresh connections
|
||
const updated = await getMyConnections()
|
||
setConnections(updated)
|
||
} catch (error) {
|
||
console.error('Failed to update trust level:', error)
|
||
} finally {
|
||
setConnectingUserId(null)
|
||
}
|
||
}
|
||
|
||
const toggleDarkMode = () => {
|
||
const newMode = !isDarkMode
|
||
setIsDarkMode(newMode)
|
||
setDarkMode(newMode)
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (editor && tools) {
|
||
setIsReady(true)
|
||
// Tools are ready
|
||
}
|
||
}, [editor, tools])
|
||
|
||
// Handle click outside profile popup
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (profilePopupRef.current && !profilePopupRef.current.contains(event.target as Node)) {
|
||
setShowProfilePopup(false)
|
||
}
|
||
}
|
||
|
||
if (showProfilePopup) {
|
||
document.addEventListener('mousedown', handleClickOutside)
|
||
}
|
||
|
||
return () => {
|
||
document.removeEventListener('mousedown', handleClickOutside)
|
||
}
|
||
}, [showProfilePopup])
|
||
|
||
// Alt+O is now handled by the tool system via overrides.tsx
|
||
// It selects the ObsidianNote tool, which waits for canvas click before deploying
|
||
|
||
// Listen for open-fathom-meetings event - now creates a shape instead of modal
|
||
useEffect(() => {
|
||
const handleOpenFathomMeetings = () => {
|
||
// Allow multiple FathomMeetingsBrowser instances
|
||
|
||
// Get the current viewport center
|
||
const viewport = editor.getViewportPageBounds()
|
||
const centerX = viewport.x + viewport.w / 2
|
||
const centerY = viewport.y + viewport.h / 2
|
||
|
||
// Position new browser shape at center
|
||
const xPosition = centerX - 350 // Center the 700px wide shape
|
||
const yPosition = centerY - 300 // Center the 600px tall shape
|
||
|
||
try {
|
||
const browserShape = editor.createShape({
|
||
type: 'FathomMeetingsBrowser',
|
||
x: xPosition,
|
||
y: yPosition,
|
||
props: {
|
||
w: 700,
|
||
h: 600,
|
||
}
|
||
})
|
||
|
||
// Select the new shape and switch to select tool
|
||
editor.setSelectedShapes([`shape:${browserShape.id}`] as any)
|
||
editor.setCurrentTool('select')
|
||
} catch (error) {
|
||
console.error('❌ Error creating FathomMeetingsBrowser shape:', error)
|
||
}
|
||
}
|
||
|
||
window.addEventListener('open-fathom-meetings', handleOpenFathomMeetings)
|
||
|
||
return () => {
|
||
window.removeEventListener('open-fathom-meetings', handleOpenFathomMeetings)
|
||
}
|
||
}, [editor])
|
||
|
||
// Listen for open-obsidian-browser event - now creates a shape instead of modal
|
||
useEffect(() => {
|
||
const handleOpenBrowser = (event?: CustomEvent) => {
|
||
// Check if ObsidianBrowser already exists
|
||
const allShapes = editor.getCurrentPageShapes()
|
||
const existingBrowserShapes = allShapes.filter(shape => shape.type === 'ObsidianBrowser')
|
||
|
||
if (existingBrowserShapes.length > 0) {
|
||
// If a browser already exists, just select it
|
||
editor.setSelectedShapes([existingBrowserShapes[0].id])
|
||
editor.setCurrentTool('hand')
|
||
return
|
||
}
|
||
|
||
// No existing browser, create a new one
|
||
|
||
// Try to get click position from event or use current page point
|
||
let xPosition: number
|
||
let yPosition: number
|
||
|
||
// Check if event has click coordinates
|
||
// Standardized size: 800x600
|
||
const shapeWidth = 800
|
||
const shapeHeight = 600
|
||
|
||
const clickPoint = (event as any)?.detail?.point
|
||
if (clickPoint) {
|
||
// Use click coordinates from event
|
||
xPosition = clickPoint.x - shapeWidth / 2
|
||
yPosition = clickPoint.y - shapeHeight / 2
|
||
} else {
|
||
// Try to get current page point (if called from a click)
|
||
const currentPagePoint = editor.inputs.currentPagePoint
|
||
if (currentPagePoint && currentPagePoint.x !== undefined && currentPagePoint.y !== undefined) {
|
||
xPosition = currentPagePoint.x - shapeWidth / 2
|
||
yPosition = currentPagePoint.y - shapeHeight / 2
|
||
} else {
|
||
// Fallback to viewport center if no click coordinates available
|
||
const viewport = editor.getViewportPageBounds()
|
||
const centerX = viewport.x + viewport.w / 2
|
||
const centerY = viewport.y + viewport.h / 2
|
||
xPosition = centerX - shapeWidth / 2
|
||
yPosition = centerY - shapeHeight / 2
|
||
}
|
||
}
|
||
|
||
try {
|
||
const browserShape = editor.createShape({
|
||
type: 'ObsidianBrowser',
|
||
x: xPosition,
|
||
y: yPosition,
|
||
props: {
|
||
w: shapeWidth,
|
||
h: shapeHeight,
|
||
}
|
||
})
|
||
|
||
// Select the new shape and switch to hand tool
|
||
editor.setSelectedShapes([`shape:${browserShape.id}`] as any)
|
||
editor.setCurrentTool('hand')
|
||
} catch (error) {
|
||
console.error('❌ Error creating ObsidianBrowser shape:', error)
|
||
}
|
||
}
|
||
|
||
window.addEventListener('open-obsidian-browser', handleOpenBrowser as EventListener)
|
||
|
||
return () => {
|
||
window.removeEventListener('open-obsidian-browser', handleOpenBrowser as EventListener)
|
||
}
|
||
}, [editor])
|
||
|
||
// Listen for open-holon-browser event - now creates a shape instead of modal
|
||
useEffect(() => {
|
||
const handleOpenHolonBrowser = () => {
|
||
// Check if a HolonBrowser shape already exists
|
||
const allShapes = editor.getCurrentPageShapes()
|
||
const existingBrowserShapes = allShapes.filter(s => s.type === 'HolonBrowser')
|
||
|
||
if (existingBrowserShapes.length > 0) {
|
||
// If a browser already exists, just select it
|
||
editor.setSelectedShapes([existingBrowserShapes[0].id])
|
||
editor.setCurrentTool('select')
|
||
return
|
||
}
|
||
|
||
// Get the current viewport center
|
||
const viewport = editor.getViewportPageBounds()
|
||
const centerX = viewport.x + viewport.w / 2
|
||
const centerY = viewport.y + viewport.h / 2
|
||
|
||
// Position new browser shape at center
|
||
const xPosition = centerX - 400 // Center the 800px wide shape
|
||
const yPosition = centerY - 300 // Center the 600px tall shape
|
||
|
||
try {
|
||
const browserShape = editor.createShape({
|
||
type: 'HolonBrowser',
|
||
x: xPosition,
|
||
y: yPosition,
|
||
props: {
|
||
w: 800,
|
||
h: 600,
|
||
}
|
||
})
|
||
|
||
// Select the new shape and switch to hand tool
|
||
editor.setSelectedShapes([`shape:${browserShape.id}`] as any)
|
||
editor.setCurrentTool('hand')
|
||
} catch (error) {
|
||
console.error('❌ Error creating HolonBrowser shape:', error)
|
||
}
|
||
}
|
||
|
||
window.addEventListener('open-holon-browser', handleOpenHolonBrowser)
|
||
|
||
return () => {
|
||
window.removeEventListener('open-holon-browser', handleOpenHolonBrowser)
|
||
}
|
||
}, [editor])
|
||
|
||
// Handle Holon selection from browser
|
||
const handleHolonSelect = (holonData: HolonData) => {
|
||
try {
|
||
// Store current camera position to prevent it from changing
|
||
const currentCamera = editor.getCamera()
|
||
editor.stopCameraAnimation()
|
||
|
||
// Get the current viewport center
|
||
const viewport = editor.getViewportPageBounds()
|
||
const centerX = viewport.x + viewport.w / 2
|
||
const centerY = viewport.y + viewport.h / 2
|
||
|
||
// Standardized size: 700x400 (matches default props to fit ID and button)
|
||
const shapeWidth = 700
|
||
const shapeHeight = 400
|
||
|
||
// Position new Holon shape at viewport center
|
||
const xPosition = centerX - shapeWidth / 2
|
||
const yPosition = centerY - shapeHeight / 2
|
||
|
||
const holonShape = editor.createShape({
|
||
type: 'Holon',
|
||
x: xPosition,
|
||
y: yPosition,
|
||
props: {
|
||
w: shapeWidth,
|
||
h: shapeHeight,
|
||
name: holonData.name,
|
||
description: holonData.description || '',
|
||
latitude: holonData.latitude,
|
||
longitude: holonData.longitude,
|
||
resolution: holonData.resolution,
|
||
holonId: holonData.id,
|
||
isConnected: true,
|
||
isEditing: false,
|
||
selectedLens: 'general',
|
||
data: holonData.data,
|
||
connections: [],
|
||
lastUpdated: holonData.timestamp
|
||
}
|
||
})
|
||
|
||
// Restore camera position if it changed
|
||
const newCamera = editor.getCamera()
|
||
if (currentCamera.x !== newCamera.x || currentCamera.y !== newCamera.y || currentCamera.z !== newCamera.z) {
|
||
editor.setCamera(currentCamera, { animation: { duration: 0 } })
|
||
}
|
||
|
||
// Don't select the new shape - let it be created without selection like other tools
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error creating Holon shape from data:', error)
|
||
}
|
||
}
|
||
|
||
// Listen for create-obsnote-shapes event from the tool
|
||
useEffect(() => {
|
||
const handleCreateShapes = () => {
|
||
// If vault browser is open, trigger shape creation
|
||
if (showVaultBrowser) {
|
||
const event = new CustomEvent('trigger-obsnote-creation')
|
||
window.dispatchEvent(event)
|
||
} else {
|
||
// If vault browser is not open, open it first
|
||
setVaultBrowserMode('keyboard')
|
||
setShowVaultBrowser(true)
|
||
}
|
||
}
|
||
|
||
window.addEventListener('create-obsnote-shapes', handleCreateShapes as EventListener)
|
||
|
||
return () => {
|
||
window.removeEventListener('create-obsnote-shapes', handleCreateShapes as EventListener)
|
||
}
|
||
}, [showVaultBrowser])
|
||
|
||
|
||
const checkApiKeys = () => {
|
||
const settings = localStorage.getItem("openai_api_key")
|
||
|
||
try {
|
||
if (settings) {
|
||
try {
|
||
const parsed = JSON.parse(settings)
|
||
if (parsed.keys) {
|
||
// New format with multiple providers
|
||
const hasValidKey = Object.values(parsed.keys).some(key =>
|
||
typeof key === 'string' && key.trim() !== ''
|
||
)
|
||
setHasApiKey(hasValidKey)
|
||
} else {
|
||
// Old format - single string
|
||
const hasValidKey = typeof settings === 'string' && settings.trim() !== ''
|
||
setHasApiKey(hasValidKey)
|
||
}
|
||
} catch (e) {
|
||
// Fallback to old format
|
||
const hasValidKey = typeof settings === 'string' && settings.trim() !== ''
|
||
setHasApiKey(hasValidKey)
|
||
}
|
||
} else {
|
||
setHasApiKey(false)
|
||
}
|
||
} catch (e) {
|
||
setHasApiKey(false)
|
||
}
|
||
}
|
||
|
||
// Initial check
|
||
useEffect(() => {
|
||
checkApiKeys()
|
||
}, [])
|
||
|
||
// Periodic check
|
||
useEffect(() => {
|
||
const interval = setInterval(checkApiKeys, 5000)
|
||
return () => clearInterval(interval)
|
||
}, [])
|
||
|
||
const handleLogout = () => {
|
||
// Clear the session
|
||
clearSession()
|
||
|
||
// Close the popup
|
||
setShowProfilePopup(false)
|
||
}
|
||
|
||
const handleObsNoteSelect = (obsNote: ObsidianObsNote) => {
|
||
// Get current camera position to place the obs_note
|
||
const camera = editor.getCamera()
|
||
const viewportCenter = editor.getViewportScreenCenter()
|
||
|
||
// Ensure we have valid coordinates - use camera position as fallback
|
||
const baseX = isNaN(viewportCenter.x) ? camera.x : viewportCenter.x
|
||
const baseY = isNaN(viewportCenter.y) ? camera.y : viewportCenter.y
|
||
|
||
// Get vault information from session
|
||
const vaultPath = session.obsidianVaultPath
|
||
const vaultName = session.obsidianVaultName
|
||
|
||
// Create a new obs_note shape with vault information
|
||
const obsNoteShape = ObsNoteShape.createFromObsidianObsNote(obsNote, baseX, baseY, createShapeId(), vaultPath, vaultName)
|
||
|
||
// Use the ObsNote shape directly - no conversion needed
|
||
const convertedShape = obsNoteShape
|
||
|
||
// Add the shape to the canvas
|
||
try {
|
||
// Store current camera position to prevent it from changing
|
||
const currentCamera = editor.getCamera()
|
||
editor.stopCameraAnimation()
|
||
|
||
editor.createShapes([convertedShape])
|
||
|
||
// Restore camera position if it changed
|
||
const newCamera = editor.getCamera()
|
||
if (currentCamera.x !== newCamera.x || currentCamera.y !== newCamera.y || currentCamera.z !== newCamera.z) {
|
||
editor.setCamera(currentCamera, { animation: { duration: 0 } })
|
||
}
|
||
|
||
// Select the newly created shape so user can see it
|
||
setTimeout(() => {
|
||
// Preserve camera position when selecting
|
||
const cameraBeforeSelect = editor.getCamera()
|
||
editor.stopCameraAnimation()
|
||
editor.setSelectedShapes([obsNoteShape.id])
|
||
editor.setCurrentTool('select')
|
||
// Restore camera if it changed during selection
|
||
const cameraAfterSelect = editor.getCamera()
|
||
if (cameraBeforeSelect.x !== cameraAfterSelect.x || cameraBeforeSelect.y !== cameraAfterSelect.y || cameraBeforeSelect.z !== cameraAfterSelect.z) {
|
||
editor.setCamera(cameraBeforeSelect, { animation: { duration: 0 } })
|
||
}
|
||
}, 100)
|
||
} catch (error) {
|
||
console.error('🎯 Error adding shape to canvas:', error)
|
||
}
|
||
|
||
// Close the browser
|
||
setShowVaultBrowser(false)
|
||
}
|
||
|
||
const handleObsNotesSelect = (obsNotes: ObsidianObsNote[]) => {
|
||
// Get current camera position to place the obs_notes
|
||
const camera = editor.getCamera()
|
||
const viewportCenter = editor.getViewportScreenCenter()
|
||
|
||
// Ensure we have valid coordinates - use camera position as fallback
|
||
const baseX = isNaN(viewportCenter.x) ? camera.x : viewportCenter.x
|
||
const baseY = isNaN(viewportCenter.y) ? camera.y : viewportCenter.y
|
||
|
||
// Get vault information from session
|
||
const vaultPath = session.obsidianVaultPath
|
||
const vaultName = session.obsidianVaultName
|
||
|
||
// Create obs_note shapes
|
||
const obsNoteShapes: any[] = []
|
||
|
||
for (let index = 0; index < obsNotes.length; index++) {
|
||
const obs_note = obsNotes[index]
|
||
|
||
// Use a grid-based position
|
||
const gridCols = 3
|
||
const gridWidth = 320
|
||
const gridHeight = 220
|
||
const xPosition = baseX + (index % gridCols) * gridWidth
|
||
const yPosition = baseY + Math.floor(index / gridCols) * gridHeight
|
||
|
||
const shape = ObsNoteShape.createFromObsidianObsNote(obs_note, xPosition, yPosition, createShapeId(), vaultPath, vaultName)
|
||
obsNoteShapes.push(shape)
|
||
}
|
||
|
||
// Use the ObsNote shapes directly - no conversion needed
|
||
const convertedShapes = obsNoteShapes
|
||
|
||
// Add all shapes to the canvas
|
||
try {
|
||
// Store current camera position to prevent it from changing
|
||
const currentCamera = editor.getCamera()
|
||
editor.stopCameraAnimation()
|
||
|
||
editor.createShapes(convertedShapes)
|
||
|
||
// Restore camera position if it changed
|
||
const newCamera = editor.getCamera()
|
||
if (currentCamera.x !== newCamera.x || currentCamera.y !== newCamera.y || currentCamera.z !== newCamera.z) {
|
||
editor.setCamera(currentCamera, { animation: { duration: 0 } })
|
||
}
|
||
|
||
// Select all newly created shapes so user can see them
|
||
const newShapeIds = obsNoteShapes.map(shape => shape.id)
|
||
setTimeout(() => {
|
||
// Preserve camera position when selecting
|
||
const cameraBeforeSelect = editor.getCamera()
|
||
editor.stopCameraAnimation()
|
||
editor.setSelectedShapes(newShapeIds)
|
||
editor.setCurrentTool('select')
|
||
// Restore camera if it changed during selection
|
||
const cameraAfterSelect = editor.getCamera()
|
||
if (cameraBeforeSelect.x !== cameraAfterSelect.x || cameraBeforeSelect.y !== cameraAfterSelect.y || cameraBeforeSelect.z !== cameraAfterSelect.z) {
|
||
editor.setCamera(cameraBeforeSelect, { animation: { duration: 0 } })
|
||
}
|
||
}, 100)
|
||
} catch (error) {
|
||
console.error('🎯 Error adding shapes to canvas:', error)
|
||
}
|
||
|
||
// Close the browser
|
||
setShowVaultBrowser(false)
|
||
}
|
||
|
||
|
||
|
||
if (!isReady) return null
|
||
|
||
return (
|
||
<div style={{ position: "relative" }}>
|
||
<div
|
||
className="toolbar-container"
|
||
style={{
|
||
pointerEvents: "auto",
|
||
}}
|
||
>
|
||
<LoginButton 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"] && (
|
||
<TldrawUiMenuItem
|
||
{...tools["VideoChat"]}
|
||
icon="video"
|
||
label="Video Chat"
|
||
isSelected={tools["VideoChat"].id === editor.getCurrentToolId()}
|
||
/>
|
||
)}
|
||
{tools["ChatBox"] && (
|
||
<TldrawUiMenuItem
|
||
{...tools["ChatBox"]}
|
||
icon="chat"
|
||
label="Chat"
|
||
isSelected={tools["ChatBox"].id === editor.getCurrentToolId()}
|
||
/>
|
||
)}
|
||
{tools["Embed"] && (
|
||
<TldrawUiMenuItem
|
||
{...tools["Embed"]}
|
||
icon="embed"
|
||
label="Embed"
|
||
isSelected={tools["Embed"].id === editor.getCurrentToolId()}
|
||
/>
|
||
)}
|
||
{tools["SlideShape"] && (
|
||
<TldrawUiMenuItem
|
||
{...tools["SlideShape"]}
|
||
icon="slides"
|
||
label="Slide"
|
||
isSelected={tools["SlideShape"].id === editor.getCurrentToolId()}
|
||
/>
|
||
)}
|
||
{tools["Markdown"] && (
|
||
<TldrawUiMenuItem
|
||
{...tools["Markdown"]}
|
||
icon="markdown"
|
||
label="Markdown"
|
||
isSelected={tools["Markdown"].id === editor.getCurrentToolId()}
|
||
/>
|
||
)}
|
||
{tools["MycrozineTemplate"] && (
|
||
<TldrawUiMenuItem
|
||
{...tools["MycrozineTemplate"]}
|
||
icon="mycrozinetemplate"
|
||
label="MycrozineTemplate"
|
||
isSelected={
|
||
tools["MycrozineTemplate"].id === editor.getCurrentToolId()
|
||
}
|
||
/>
|
||
)}
|
||
{tools["Prompt"] && (
|
||
<TldrawUiMenuItem
|
||
{...tools["Prompt"]}
|
||
icon="prompt"
|
||
label="LLM Prompt"
|
||
isSelected={tools["Prompt"].id === editor.getCurrentToolId()}
|
||
/>
|
||
)}
|
||
{tools["ObsidianNote"] && (
|
||
<TldrawUiMenuItem
|
||
{...tools["ObsidianNote"]}
|
||
icon="file-text"
|
||
label="Obsidian Note"
|
||
isSelected={tools["ObsidianNote"].id === editor.getCurrentToolId()}
|
||
/>
|
||
)}
|
||
{tools["Transcription"] && (
|
||
<TldrawUiMenuItem
|
||
{...tools["Transcription"]}
|
||
icon="microphone"
|
||
label="Transcription"
|
||
isSelected={tools["Transcription"].id === editor.getCurrentToolId()}
|
||
/>
|
||
)}
|
||
{tools["Holon"] && (
|
||
<TldrawUiMenuItem
|
||
{...tools["Holon"]}
|
||
icon="globe"
|
||
label="Holon"
|
||
isSelected={tools["Holon"].id === editor.getCurrentToolId()}
|
||
/>
|
||
)}
|
||
{tools["FathomMeetings"] && (
|
||
<TldrawUiMenuItem
|
||
{...tools["FathomMeetings"]}
|
||
icon="calendar"
|
||
label="Fathom Meetings"
|
||
isSelected={tools["FathomMeetings"].id === editor.getCurrentToolId()}
|
||
/>
|
||
)}
|
||
{tools["ImageGen"] && (
|
||
<TldrawUiMenuItem
|
||
{...tools["ImageGen"]}
|
||
icon="image"
|
||
label="Image Generation"
|
||
isSelected={tools["ImageGen"].id === editor.getCurrentToolId()}
|
||
/>
|
||
)}
|
||
{tools["VideoGen"] && (
|
||
<TldrawUiMenuItem
|
||
{...tools["VideoGen"]}
|
||
icon="video"
|
||
label="Video Generation"
|
||
isSelected={tools["VideoGen"].id === editor.getCurrentToolId()}
|
||
/>
|
||
)}
|
||
{tools["Multmux"] && (
|
||
<TldrawUiMenuItem
|
||
{...tools["Multmux"]}
|
||
icon="terminal"
|
||
label="Terminal"
|
||
isSelected={tools["Multmux"].id === editor.getCurrentToolId()}
|
||
/>
|
||
)}
|
||
{tools["Map"] && (
|
||
<TldrawUiMenuItem
|
||
{...tools["Map"]}
|
||
icon="geo-globe"
|
||
label="Map"
|
||
isSelected={tools["Map"].id === editor.getCurrentToolId()}
|
||
/>
|
||
)}
|
||
{/* MycelialIntelligence moved to permanent floating bar */}
|
||
{/* Share Location tool removed for now */}
|
||
{/* Refresh All ObsNotes Button */}
|
||
{(() => {
|
||
const allShapes = editor.getCurrentPageShapes()
|
||
const obsNoteShapes = allShapes.filter(shape => shape.type === 'ObsNote')
|
||
return obsNoteShapes.length > 0 && (
|
||
<TldrawUiMenuItem
|
||
id="refresh-all-obsnotes"
|
||
icon="refresh-cw"
|
||
label="Refresh All Notes"
|
||
onSelect={() => {
|
||
const event = new CustomEvent('refresh-all-obsnotes')
|
||
window.dispatchEvent(event)
|
||
}}
|
||
/>
|
||
)
|
||
})()}
|
||
</DefaultToolbar>
|
||
|
||
{/* Fathom Meetings Panel */}
|
||
{showFathomPanel && (
|
||
<FathomMeetingsPanel
|
||
onClose={() => setShowFathomPanel(false)}
|
||
/>
|
||
)}
|
||
|
||
</div>
|
||
)
|
||
}
|