canvas-website/src/ui/components.tsx

1214 lines
50 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React from "react"
import { createPortal } from "react-dom"
import { useParams } from "react-router-dom"
import { CustomMainMenu } from "./CustomMainMenu"
import { CustomToolbar } from "./CustomToolbar"
import { CustomContextMenu } from "./CustomContextMenu"
import { FocusLockIndicator } from "./FocusLockIndicator"
import { MycelialIntelligenceBar } from "./MycelialIntelligenceBar"
import { CommandPalette, openCommandPalette } from "./CommandPalette"
import { NetworkGraphPanel } from "../components/networking"
import CryptIDDropdown from "../components/auth/CryptIDDropdown"
import StarBoardButton from "../components/StarBoardButton"
import ShareBoardButton from "../components/ShareBoardButton"
import { SettingsDialog } from "./SettingsDialog"
import * as crypto from "../lib/auth/crypto"
// import { VersionHistoryPanel } from "../components/history" // TODO: Re-enable when version reversion is ready
import { useAuth } from "../context/AuthContext"
import { PermissionLevel } from "../lib/auth/types"
import { WORKER_URL } from "../constants/workerUrl"
import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
TLComponents,
TldrawUiMenuItem,
useTools,
useActions,
useDialogs,
} from "tldraw"
import { SlidesPanel } from "@/slides/SlidesPanel"
// AI tool model configurations
const AI_TOOLS = [
{ id: 'chat', name: 'Chat', icon: '💬', model: 'llama3.1:8b', provider: 'Ollama', type: 'local' },
{ id: 'make-real', name: 'Make Real', icon: '🔧', model: 'claude-sonnet-4-5', provider: 'Anthropic', type: 'cloud' },
{ id: 'image-gen', name: 'Image Gen', icon: '🎨', model: 'SDXL', provider: 'RunPod', type: 'gpu' },
{ id: 'video-gen', name: 'Video Gen', icon: '🎬', model: 'Wan2.1', provider: 'RunPod', type: 'gpu' },
{ id: 'transcription', name: 'Transcribe', icon: '🎤', model: 'Web Speech', provider: 'Browser', type: 'local' },
{ id: 'mycelial', name: 'Mycelial', icon: '🍄', model: 'llama3.1:70b', provider: 'Ollama', type: 'local' },
];
// Permission labels and colors
const PERMISSION_CONFIG: Record<PermissionLevel, { label: string; color: string; icon: string }> = {
view: { label: 'View Only', color: '#6b7280', icon: '👁️' },
edit: { label: 'Edit', color: '#3b82f6', icon: '✏️' },
admin: { label: 'Admin', color: '#10b981', icon: '👑' },
}
// Custom SharePanel with layout: CryptID -> Star -> Gear -> Question mark
function CustomSharePanel() {
const { addDialog, removeDialog } = useDialogs()
const { session } = useAuth()
const { slug } = useParams<{ slug: string }>()
const boardId = slug || 'mycofi33'
const [showSettingsDropdown, setShowSettingsDropdown] = React.useState(false)
// const [showVersionHistory, setShowVersionHistory] = React.useState(false) // TODO: Re-enable when version reversion is ready
const [showAISection, setShowAISection] = React.useState(false)
const [hasApiKey, setHasApiKey] = React.useState(false)
const [permissionRequestStatus, setPermissionRequestStatus] = React.useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
const [requestMessage, setRequestMessage] = React.useState('')
// Board protection state
const [boardProtected, setBoardProtected] = React.useState(false)
const [protectionLoading, setProtectionLoading] = React.useState(false)
const [isGlobalAdmin, setIsGlobalAdmin] = React.useState(false)
const [isBoardAdmin, setIsBoardAdmin] = React.useState(false)
const [editors, setEditors] = React.useState<Array<{ userId: string; username: string; permission: string }>>([])
const [inviteInput, setInviteInput] = React.useState('')
const [inviteStatus, setInviteStatus] = React.useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
// Refs for dropdown positioning
const settingsButtonRef = React.useRef<HTMLButtonElement>(null)
const [settingsDropdownPos, setSettingsDropdownPos] = React.useState<{ top: number; right: number } | null>(null)
// Get current permission from session
// Authenticated users default to 'edit', unauthenticated to 'view'
const currentPermission: PermissionLevel = session.currentBoardPermission || (session.authed ? 'edit' : 'view')
// Request permission upgrade
const handleRequestPermission = async (requestedLevel: PermissionLevel) => {
if (!session.authed || !session.username) {
setRequestMessage('Please sign in to request permissions')
return
}
setPermissionRequestStatus('sending')
try {
const response = await fetch(`${WORKER_URL}/boards/${boardId}/permission-request`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: session.username,
email: session.email,
requestedPermission: requestedLevel,
currentPermission,
boardId,
}),
})
if (response.ok) {
setPermissionRequestStatus('sent')
setRequestMessage(`Request for ${PERMISSION_CONFIG[requestedLevel].label} access sent to board admins`)
setTimeout(() => {
setPermissionRequestStatus('idle')
setRequestMessage('')
}, 5000)
} else {
throw new Error('Failed to send request')
}
} catch (error) {
console.error('Permission request error:', error)
setPermissionRequestStatus('error')
setRequestMessage('Failed to send request. Please try again.')
setTimeout(() => {
setPermissionRequestStatus('idle')
setRequestMessage('')
}, 3000)
}
}
// Update dropdown positions when they open
React.useEffect(() => {
if (showSettingsDropdown && settingsButtonRef.current) {
const rect = settingsButtonRef.current.getBoundingClientRect()
setSettingsDropdownPos({
top: rect.bottom + 8,
right: window.innerWidth - rect.right,
})
}
}, [showSettingsDropdown])
// ESC key handler for closing dropdowns
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
if (showSettingsDropdown) setShowSettingsDropdown(false)
}
}
if (showSettingsDropdown) {
// Use capture phase to intercept before tldraw
document.addEventListener('keydown', handleKeyDown, true)
}
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [showSettingsDropdown])
// Detect dark mode - use state to trigger re-render on change
const [isDarkMode, setIsDarkMode] = React.useState(
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
)
// Check for API keys on mount
React.useEffect(() => {
const checkApiKeys = () => {
const keys = localStorage.getItem('apiKeys')
if (keys) {
try {
const parsed = JSON.parse(keys)
setHasApiKey(!!(parsed.openai || parsed.anthropic || parsed.google))
} catch {
setHasApiKey(false)
}
}
}
checkApiKeys()
}, [])
const handleToggleDarkMode = () => {
const newIsDark = !document.documentElement.classList.contains('dark')
document.documentElement.classList.toggle('dark')
localStorage.setItem('theme', newIsDark ? 'dark' : 'light')
setIsDarkMode(newIsDark)
}
const handleManageApiKeys = () => {
setShowSettingsDropdown(false)
addDialog({
id: "api-keys",
component: ({ onClose: dialogClose }: { onClose: () => void }) => (
<SettingsDialog
onClose={() => {
dialogClose()
removeDialog("api-keys")
// Recheck API keys after dialog closes
const keys = localStorage.getItem('apiKeys')
if (keys) {
try {
const parsed = JSON.parse(keys)
setHasApiKey(!!(parsed.openai || parsed.anthropic || parsed.google))
} catch {
setHasApiKey(false)
}
}
}}
/>
),
})
}
// Get auth headers for API calls
const getAuthHeaders = React.useCallback((): Record<string, string> => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (session.authed && session.username) {
const publicKey = crypto.getPublicKey(session.username)
if (publicKey) {
headers['X-CryptID-PublicKey'] = publicKey
}
}
return headers
}, [session.authed, session.username])
// Fetch board info when settings dropdown opens
const fetchBoardInfo = React.useCallback(async () => {
if (!showSettingsDropdown) return
setProtectionLoading(true)
try {
const headers = getAuthHeaders()
// Fetch board info
const infoRes = await fetch(`${WORKER_URL}/boards/${boardId}/info`, { headers })
if (infoRes.ok) {
const infoData = await infoRes.json() as { board?: { isProtected?: boolean } }
if (infoData.board) {
setBoardProtected(infoData.board.isProtected || false)
}
}
// Fetch permission to check if admin
const permRes = await fetch(`${WORKER_URL}/boards/${boardId}/permission`, { headers })
if (permRes.ok) {
const permData = await permRes.json() as { permission?: string; isGlobalAdmin?: boolean }
setIsBoardAdmin(permData.permission === 'admin')
setIsGlobalAdmin(permData.isGlobalAdmin || false)
// If admin, fetch editors list
if (permData.permission === 'admin') {
const editorsRes = await fetch(`${WORKER_URL}/boards/${boardId}/editors`, { headers })
if (editorsRes.ok) {
const editorsData = await editorsRes.json() as { editors?: Array<{ userId: string; username: string; permission: string }> }
setEditors(editorsData.editors || [])
}
}
}
} catch (error) {
console.error('Failed to fetch board data:', error)
} finally {
setProtectionLoading(false)
}
}, [showSettingsDropdown, boardId, getAuthHeaders])
// Fetch board info when dropdown opens
React.useEffect(() => {
if (showSettingsDropdown) {
fetchBoardInfo()
}
}, [showSettingsDropdown, fetchBoardInfo])
// Toggle board protection
const handleToggleProtection = async () => {
if (protectionLoading) return
setProtectionLoading(true)
try {
const headers = getAuthHeaders()
const res = await fetch(`${WORKER_URL}/boards/${boardId}`, {
method: 'PATCH',
headers,
body: JSON.stringify({ isProtected: !boardProtected }),
})
if (res.ok) {
setBoardProtected(!boardProtected)
// Refresh editors list if now protected
if (!boardProtected) {
const editorsRes = await fetch(`${WORKER_URL}/boards/${boardId}/editors`, { headers })
if (editorsRes.ok) {
const editorsData = await editorsRes.json() as { editors?: Array<{ userId: string; username: string; permission: string }> }
setEditors(editorsData.editors || [])
}
}
}
} catch (error) {
console.error('Failed to toggle protection:', error)
} finally {
setProtectionLoading(false)
}
}
// Invite user as editor
const handleInviteEditor = async () => {
if (!inviteInput.trim() || inviteStatus === 'sending') return
setInviteStatus('sending')
try {
const headers = getAuthHeaders()
const res = await fetch(`${WORKER_URL}/boards/${boardId}/permissions`, {
method: 'POST',
headers,
body: JSON.stringify({
usernameOrEmail: inviteInput.trim(),
permission: 'edit',
}),
})
if (res.ok) {
setInviteStatus('sent')
setInviteInput('')
// Refresh editors list
const editorsRes = await fetch(`${WORKER_URL}/boards/${boardId}/editors`, { headers })
if (editorsRes.ok) {
const editorsData = await editorsRes.json() as { editors?: Array<{ userId: string; username: string; permission: string }> }
setEditors(editorsData.editors || [])
}
setTimeout(() => setInviteStatus('idle'), 2000)
} else {
setInviteStatus('error')
setTimeout(() => setInviteStatus('idle'), 3000)
}
} catch (error) {
console.error('Failed to invite editor:', error)
setInviteStatus('error')
setTimeout(() => setInviteStatus('idle'), 3000)
}
}
// Remove editor
const handleRemoveEditor = async (userId: string) => {
try {
const headers = getAuthHeaders()
await fetch(`${WORKER_URL}/boards/${boardId}/permissions/${userId}`, {
method: 'DELETE',
headers,
})
setEditors(prev => prev.filter(e => e.userId !== userId))
} catch (error) {
console.error('Failed to remove editor:', error)
}
}
// Separator component for unified menu
const Separator = () => (
<div style={{
width: '1px',
height: '20px',
background: 'var(--color-panel-contrast)',
opacity: 0.5,
}} />
)
return (
<div className="tlui-share-zone" draggable={false} style={{ position: 'relative' }}>
{/* Unified menu container - grey oval */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0',
background: isDarkMode ? '#2d2d2d' : '#f3f4f6',
backgroundColor: isDarkMode ? '#2d2d2d' : '#f3f4f6',
backdropFilter: 'none',
opacity: 1,
borderRadius: '20px',
border: `1px solid ${isDarkMode ? '#404040' : '#e5e7eb'}`,
padding: '4px 6px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}>
{/* CryptID dropdown - leftmost */}
<div style={{ padding: '0 4px' }}>
<CryptIDDropdown isDarkMode={isDarkMode} />
</div>
<Separator />
{/* Share board button */}
<div style={{ padding: '0 2px' }}>
<ShareBoardButton className="share-panel-btn" />
</div>
<Separator />
{/* Star board button */}
<div style={{ padding: '0 2px' }}>
<StarBoardButton className="share-panel-btn" />
</div>
<Separator />
{/* Settings gear button with dropdown */}
<div style={{ padding: '0 2px' }}>
<button
ref={settingsButtonRef}
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',
}}
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 - rendered via portal to break out of parent container */}
{showSettingsDropdown && settingsDropdownPos && createPortal(
<>
{/* Backdrop - only uses onClick, not onPointerDown */}
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 99998,
background: 'transparent',
}}
onClick={() => setShowSettingsDropdown(false)}
/>
{/* Dropdown menu */}
<div
style={{
position: 'fixed',
top: settingsDropdownPos.top,
right: settingsDropdownPos.right,
minWidth: '220px',
maxHeight: '60vh',
overflowY: 'auto',
overflowX: 'hidden',
background: 'var(--color-panel)',
backgroundColor: 'var(--color-panel)',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '8px',
boxShadow: isDarkMode ? '0 4px 20px rgba(0,0,0,0.5)' : '0 4px 20px rgba(0,0,0,0.25)',
zIndex: 99999,
padding: '8px 0',
pointerEvents: 'auto',
backdropFilter: 'none',
opacity: 1,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
}}
onWheel={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{/* Board Permission Section */}
<div style={{ padding: '12px 16px 16px' }}>
{/* Section Header */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '12px',
paddingBottom: '8px',
borderBottom: '1px solid var(--color-panel-contrast)',
}}>
<span style={{ fontSize: '14px' }}>🔐</span>
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>Board Permission</span>
<span style={{
marginLeft: 'auto',
fontSize: '10px',
padding: '3px 8px',
borderRadius: '12px',
background: `${PERMISSION_CONFIG[currentPermission].color}20`,
color: PERMISSION_CONFIG[currentPermission].color,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.3px',
}}>
{PERMISSION_CONFIG[currentPermission].label}
</span>
</div>
{/* Permission levels - indented to show hierarchy */}
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '6px',
marginLeft: '4px',
padding: '8px 12px',
background: 'var(--color-muted-2)',
borderRadius: '8px',
border: '1px solid var(--color-panel-contrast)',
}}>
<span style={{ fontSize: '10px', color: 'var(--color-text-3)', marginBottom: '4px', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Access Levels
</span>
{(['view', 'edit', 'admin'] as PermissionLevel[]).map((level) => {
const config = PERMISSION_CONFIG[level]
const isCurrent = currentPermission === level
const canRequest = session.authed && !isCurrent && (
(level === 'edit' && currentPermission === 'view') ||
(level === 'admin' && currentPermission !== 'admin')
)
return (
<div
key={level}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 10px',
borderRadius: '6px',
background: isCurrent ? `${config.color}15` : 'var(--color-panel)',
border: isCurrent ? `2px solid ${config.color}` : '1px solid var(--color-panel-contrast)',
transition: 'all 0.15s ease',
}}
>
<span style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '12px',
color: isCurrent ? config.color : 'var(--color-text)',
fontWeight: isCurrent ? 600 : 400,
}}>
<span style={{ fontSize: '14px' }}>{config.icon}</span>
<span>{config.label}</span>
{isCurrent && (
<span style={{
fontSize: '9px',
padding: '2px 6px',
borderRadius: '10px',
background: config.color,
color: 'white',
fontWeight: 500,
}}>
Current
</span>
)}
</span>
{canRequest && (
<button
onClick={() => handleRequestPermission(level)}
disabled={permissionRequestStatus === 'sending'}
style={{
padding: '4px 10px',
fontSize: '10px',
fontWeight: 600,
borderRadius: '4px',
border: `1px solid ${config.color}`,
background: 'transparent',
color: config.color,
cursor: permissionRequestStatus === 'sending' ? 'wait' : 'pointer',
opacity: permissionRequestStatus === 'sending' ? 0.6 : 1,
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = config.color
e.currentTarget.style.color = 'white'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = config.color
}}
>
{permissionRequestStatus === 'sending' ? '...' : 'Request'}
</button>
)}
</div>
)
})}
</div>
{/* Request status message */}
{requestMessage && (
<p style={{
margin: '10px 0 0',
fontSize: '11px',
padding: '8px 12px',
borderRadius: '6px',
background: permissionRequestStatus === 'sent' ? '#d1fae5' :
permissionRequestStatus === 'error' ? '#fee2e2' : 'var(--color-muted-2)',
color: permissionRequestStatus === 'sent' ? '#065f46' :
permissionRequestStatus === 'error' ? '#dc2626' : 'var(--color-text-3)',
textAlign: 'center',
}}>
{requestMessage}
</p>
)}
{!session.authed && (
<p style={{
margin: '10px 0 0',
fontSize: '10px',
color: 'var(--color-text-3)',
textAlign: 'center',
fontStyle: 'italic',
}}>
Sign in to request higher permissions
</p>
)}
</div>
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '0' }} />
{/* Board Protection Section - only for admins */}
{isBoardAdmin && (
<div style={{ padding: '12px 16px' }}>
{/* Section Header */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '12px',
}}>
<span style={{ fontSize: '14px' }}>🛡</span>
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>Board Protection</span>
{isGlobalAdmin && (
<span style={{
fontSize: '9px',
padding: '2px 6px',
borderRadius: '10px',
background: '#3b82f620',
color: '#3b82f6',
fontWeight: 600,
}}>
Global Admin
</span>
)}
</div>
{/* Protection Toggle */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
background: 'var(--color-muted-2)',
borderRadius: '8px',
border: '1px solid var(--color-panel-contrast)',
marginBottom: boardProtected ? '12px' : '0',
}}>
<div>
<div style={{ fontSize: '12px', fontWeight: 500, color: 'var(--color-text)' }}>
View-only Mode
</div>
<div style={{ fontSize: '10px', color: 'var(--color-text-3)' }}>
{boardProtected ? 'Only listed editors can make changes' : 'Anyone can edit this board'}
</div>
</div>
<button
onClick={handleToggleProtection}
disabled={protectionLoading}
style={{
width: '44px',
height: '24px',
borderRadius: '12px',
border: 'none',
cursor: protectionLoading ? 'not-allowed' : 'pointer',
background: boardProtected ? '#3b82f6' : '#d1d5db',
position: 'relative',
transition: 'background 0.2s',
opacity: protectionLoading ? 0.5 : 1,
}}
>
<div style={{
width: '20px',
height: '20px',
borderRadius: '10px',
background: 'white',
position: 'absolute',
top: '2px',
left: boardProtected ? '22px' : '2px',
transition: 'left 0.2s',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
</div>
{/* Editor Management - only when protected */}
{boardProtected && (
<div style={{
padding: '10px 12px',
background: 'var(--color-muted-2)',
borderRadius: '8px',
border: '1px solid var(--color-panel-contrast)',
}}>
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-3)', marginBottom: '8px', textTransform: 'uppercase' }}>
Editors ({editors.length})
</div>
{/* Add Editor Input */}
<div style={{ display: 'flex', gap: '8px', marginBottom: editors.length > 0 ? '10px' : '0' }}>
<input
type="text"
placeholder="Username or email..."
value={inviteInput}
onChange={(e) => setInviteInput(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') handleInviteEditor()
}}
style={{
flex: 1,
padding: '8px 12px',
fontSize: '12px',
fontFamily: 'inherit',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '6px',
background: 'var(--color-panel)',
color: 'var(--color-text)',
outline: 'none',
}}
/>
<button
onClick={handleInviteEditor}
disabled={!inviteInput.trim() || inviteStatus === 'sending'}
style={{
padding: '8px 14px',
backgroundColor: inviteStatus === 'sent' ? '#10b981' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: !inviteInput.trim() || inviteStatus === 'sending' ? 'not-allowed' : 'pointer',
fontSize: '11px',
fontWeight: 500,
fontFamily: 'inherit',
opacity: !inviteInput.trim() ? 0.5 : 1,
}}
>
{inviteStatus === 'sending' ? '...' : inviteStatus === 'sent' ? 'Added' : 'Add'}
</button>
</div>
{/* Editor List */}
{editors.length > 0 && (
<div style={{ maxHeight: '120px', overflowY: 'auto' }}>
{editors.map((editor) => (
<div
key={editor.userId}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 8px',
borderRadius: '6px',
marginBottom: '4px',
background: 'var(--color-panel)',
}}
>
<span style={{ fontSize: '12px', fontWeight: 500, color: 'var(--color-text)' }}>
@{editor.username}
</span>
<button
onClick={() => handleRemoveEditor(editor.userId)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: '#ef4444',
fontSize: '12px',
padding: '2px 6px',
opacity: 0.7,
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1' }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.7' }}
title="Remove editor"
>
×
</button>
</div>
))}
</div>
)}
{editors.length === 0 && (
<div style={{ fontSize: '10px', color: 'var(--color-text-3)', textAlign: 'center', padding: '4px' }}>
No editors added yet
</div>
)}
</div>
)}
</div>
)}
{isBoardAdmin && (
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '0' }} />
)}
{/* Appearance Toggle */}
<div style={{ padding: '12px 16px' }}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>
<span style={{ fontSize: '14px' }}>🎨</span>
<span>Appearance</span>
</span>
{/* Toggle Switch */}
<button
onClick={handleToggleDarkMode}
style={{
display: 'flex',
alignItems: 'center',
gap: '0',
padding: '3px',
background: isDarkMode ? '#374151' : '#e5e7eb',
border: 'none',
borderRadius: '20px',
cursor: 'pointer',
transition: 'background 0.2s ease',
}}
>
{/* Sun icon */}
<span style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
borderRadius: '50%',
background: !isDarkMode ? '#ffffff' : 'transparent',
boxShadow: !isDarkMode ? '0 1px 3px rgba(0,0,0,0.2)' : 'none',
transition: 'all 0.2s ease',
fontSize: '14px',
}}>
</span>
{/* Moon icon */}
<span style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
borderRadius: '50%',
background: isDarkMode ? '#1f2937' : 'transparent',
boxShadow: isDarkMode ? '0 1px 3px rgba(0,0,0,0.3)' : 'none',
transition: 'all 0.2s ease',
fontSize: '14px',
}}>
🌙
</span>
</button>
</div>
</div>
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '0' }} />
{/* AI Models Accordion */}
<div>
<button
onClick={() => setShowAISection(!showAISection)}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
background: showAISection ? 'var(--color-muted-2)' : 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-text)',
fontSize: '13px',
fontWeight: 600,
textAlign: 'left',
transition: 'background 0.15s ease',
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
if (!showAISection) e.currentTarget.style.background = 'var(--color-muted-2)'
}}
onMouseLeave={(e) => {
if (!showAISection) e.currentTarget.style.background = 'none'
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px' }}>🤖</span>
<span>AI Models</span>
<span style={{
fontSize: '9px',
padding: '2px 6px',
borderRadius: '10px',
background: 'var(--color-muted-2)',
color: 'var(--color-text-3)',
}}>
{AI_TOOLS.length}
</span>
</span>
<span style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '20px',
height: '20px',
borderRadius: '4px',
background: showAISection ? 'var(--color-panel)' : 'var(--color-muted-2)',
transition: 'all 0.2s ease',
}}>
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="currentColor"
style={{
transform: showAISection ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease',
color: 'var(--color-text-3)',
}}
>
<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>
</span>
</button>
{showAISection && (
<div style={{
padding: '12px 16px',
background: 'var(--color-muted-2)',
borderTop: '1px solid var(--color-panel-contrast)',
}}>
<p style={{
fontSize: '11px',
color: 'var(--color-text-3)',
marginBottom: '12px',
padding: '8px 10px',
background: 'var(--color-panel)',
borderRadius: '6px',
border: '1px solid var(--color-panel-contrast)',
}}>
💡 <strong>Local models</strong> are free. <strong>Cloud models</strong> require API keys.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{AI_TOOLS.map((tool) => (
<div
key={tool.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 10px',
background: 'var(--color-panel)',
borderRadius: '6px',
border: '1px solid var(--color-panel-contrast)',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', color: 'var(--color-text)' }}>
<span style={{ fontSize: '14px' }}>{tool.icon}</span>
<span style={{ fontWeight: 500 }}>{tool.name}</span>
</span>
<span
style={{
fontSize: '9px',
padding: '3px 8px',
borderRadius: '12px',
backgroundColor: tool.type === 'local' ? '#d1fae5' : tool.type === 'gpu' ? '#e0e7ff' : '#fef3c7',
color: tool.type === 'local' ? '#065f46' : tool.type === 'gpu' ? '#3730a3' : '#92400e',
fontWeight: 600,
}}
>
{tool.model}
</span>
</div>
))}
</div>
<button
onClick={handleManageApiKeys}
style={{
width: '100%',
marginTop: '12px',
padding: '8px 12px',
fontSize: '11px',
fontWeight: 500,
fontFamily: 'inherit',
backgroundColor: 'var(--color-primary, #3b82f6)',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
transition: 'background 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#2563eb'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'var(--color-primary, #3b82f6)'
}}
>
<span>🔑</span>
{hasApiKey ? 'Manage API Keys' : 'Add API Keys'}
</button>
</div>
)}
</div>
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '0' }} />
{/* Version Reversion - Coming Soon */}
<div style={{ padding: '12px 16px' }}>
{/* Section Header - matches other headers */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '10px',
}}>
<span style={{ fontSize: '14px' }}>🕐</span>
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>Version Reversion</span>
</div>
{/* Coming Soon Button */}
<button
disabled
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
padding: '8px 12px',
background: 'var(--color-muted-2)',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '6px',
cursor: 'not-allowed',
color: 'var(--color-text-3)',
fontSize: '11px',
fontWeight: 500,
fontFamily: 'inherit',
}}
>
Coming soon
</button>
</div>
</div>
</>,
document.body
)}
</div>
<Separator />
{/* Help/Keyboard shortcuts button - rightmost - opens Command Palette */}
<div style={{ padding: '0 4px' }}>
<button
onClick={() => openCommandPalette()}
className="share-panel-btn"
style={{
background: 'none',
border: 'none',
padding: '6px',
cursor: 'pointer',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-1)',
opacity: 0.7,
transition: 'opacity 0.15s, background 0.15s',
pointerEvents: 'all',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1'
e.currentTarget.style.background = 'var(--color-muted-2)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.7'
e.currentTarget.style.background = 'none'
}}
title="Keyboard shortcuts (?)"
>
<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="10"></circle>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</button>
</div>
</div>
{/* Version Reversion Panel - Coming Soon */}
{/* TODO: Re-enable when version history backend is fully tested
{showVersionHistory && createPortal(
<VersionHistoryPanel
roomId={boardId}
onClose={() => setShowVersionHistory(false)}
onRevert={(hash) => {
console.log('Reverted to version:', hash)
window.location.reload()
}}
isDarkMode={isDarkMode}
/>,
document.body
)}
*/}
</div>
)
}
// Combined InFrontOfCanvas component for floating UI elements
function CustomInFrontOfCanvas() {
return (
<>
<MycelialIntelligenceBar />
<FocusLockIndicator />
<CommandPalette />
<NetworkGraphPanel />
</>
)
}
export const components: TLComponents = {
Toolbar: CustomToolbar,
MainMenu: CustomMainMenu,
ContextMenu: CustomContextMenu,
HelperButtons: SlidesPanel,
SharePanel: CustomSharePanel,
InFrontOfTheCanvas: CustomInFrontOfCanvas,
KeyboardShortcutsDialog: (props: any) => {
const tools = useTools()
const actions = useActions()
// Get all custom tools with keyboard shortcuts
const customTools = [
tools["VideoChat"],
tools["ChatBox"],
tools["Embed"],
tools["Slide"],
tools["Markdown"],
tools["MycrozineTemplate"],
tools["Prompt"],
tools["ObsidianNote"],
tools["Transcription"],
tools["Holon"],
tools["FathomMeetings"],
tools["ImageGen"],
// tools["VideoGen"], // Temporarily hidden
tools["Multmux"],
// MycelialIntelligence moved to permanent floating bar
].filter(tool => tool && tool.kbd)
// Get all custom actions with keyboard shortcuts
const customActions = [
actions["zoom-in"],
actions["zoom-out"],
actions["zoom-to-selection"],
actions["copy-link-to-current-view"],
actions["copy-focus-link"],
actions["unlock-camera-focus"],
actions["revert-camera"],
actions["lock-element"],
actions["save-to-pdf"],
actions["search-shapes"],
actions["llm"],
actions["open-obsidian-browser"],
].filter(action => action && action.kbd)
return (
<DefaultKeyboardShortcutsDialog {...props}>
{/* Custom Tools */}
{customTools.map(tool => (
<TldrawUiMenuItem
key={tool.id}
id={tool.id}
label={tool.label}
icon={typeof tool.icon === 'string' ? tool.icon : undefined}
kbd={tool.kbd}
onSelect={tool.onSelect}
/>
))}
{/* Custom Actions */}
{customActions.map(action => (
<TldrawUiMenuItem
key={action.id}
id={action.id}
label={action.label}
icon={typeof action.icon === 'string' ? action.icon : undefined}
kbd={action.kbd}
onSelect={action.onSelect}
/>
))}
{/* Default content (includes standard TLDraw shortcuts) */}
<DefaultKeyboardShortcutsDialogContent />
</DefaultKeyboardShortcutsDialog>
)
},
}