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 = { 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>([]) const [inviteInput, setInviteInput] = React.useState('') const [inviteStatus, setInviteStatus] = React.useState<'idle' | 'sending' | 'sent' | 'error'>('idle') // Refs for dropdown positioning const settingsButtonRef = React.useRef(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 }) => ( { 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 => { const headers: Record = { '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 = () => (
) return (
{/* Unified menu container - grey oval */}
{/* CryptID dropdown - leftmost */}
{/* Share board button */}
{/* Star board button */}
{/* Settings gear button with dropdown */}
{/* Settings dropdown - rendered via portal to break out of parent container */} {showSettingsDropdown && settingsDropdownPos && createPortal( <> {/* Backdrop - only uses onClick, not onPointerDown */}
setShowSettingsDropdown(false)} /> {/* Dropdown menu */}
e.stopPropagation()} onClick={(e) => e.stopPropagation()} > {/* Board Permission Section */}
{/* Section Header */}
🔐 Board Permission {PERMISSION_CONFIG[currentPermission].label}
{/* Permission levels - indented to show hierarchy */}
Access Levels {(['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 (
{config.icon} {config.label} {isCurrent && ( Current )} {canRequest && ( )}
) })}
{/* Request status message */} {requestMessage && (

{requestMessage}

)} {!session.authed && (

Sign in to request higher permissions

)}
{/* Board Protection Section - only for admins */} {isBoardAdmin && (
{/* Section Header */}
🛡️ Board Protection {isGlobalAdmin && ( Global Admin )}
{/* Protection Toggle */}
View-only Mode
{boardProtected ? 'Only listed editors can make changes' : 'Anyone can edit this board'}
{/* Editor Management - only when protected */} {boardProtected && (
Editors ({editors.length})
{/* Add Editor Input */}
0 ? '10px' : '0' }}> 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', }} />
{/* Editor List */} {editors.length > 0 && (
{editors.map((editor) => (
@{editor.username}
))}
)} {editors.length === 0 && (
No editors added yet
)}
)}
)} {isBoardAdmin && (
)} {/* Appearance Toggle */}
🎨 Appearance {/* Toggle Switch */}
{/* AI Models Accordion */}
{showAISection && (

💡 Local models are free. Cloud models require API keys.

{AI_TOOLS.map((tool) => (
{tool.icon} {tool.name} {tool.model}
))}
)}
{/* Version Reversion - Coming Soon */}
{/* Section Header - matches other headers */}
🕐 Version Reversion
{/* Coming Soon Button */}
, document.body )}
{/* Help/Keyboard shortcuts button - rightmost - opens Command Palette */}
{/* Version Reversion Panel - Coming Soon */} {/* TODO: Re-enable when version history backend is fully tested {showVersionHistory && createPortal( setShowVersionHistory(false)} onRevert={(hash) => { console.log('Reverted to version:', hash) window.location.reload() }} isDarkMode={isDarkMode} />, document.body )} */}
) } // Combined InFrontOfCanvas component for floating UI elements function CustomInFrontOfCanvas() { return ( <> ) } 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 ( {/* Custom Tools */} {customTools.map(tool => ( ))} {/* Custom Actions */} {customActions.map(action => ( ))} {/* Default content (includes standard TLDraw shortcuts) */} ) }, }