feat: add Drawfast tool, improve share UI, and various UI enhancements

New features:
- Add Drawfast tool and shape for quick drawing
- Add useLiveImage hook for real-time image generation
- Improve ShareBoardButton with better UI and functionality

UI improvements:
- Refactor CryptIDDropdown for cleaner interface
- Enhance components.tsx with better tool visibility handling
- Add context menu and toolbar enhancements
- Update MycelialIntelligenceBar styling

Backend:
- Add board permissions API endpoints
- Update worker with new networking routes
- Add html2canvas dependency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-15 00:03:12 -05:00
parent 6f68fcd4ae
commit 2988b84689
20 changed files with 1785 additions and 397 deletions

View File

@ -4,6 +4,7 @@
<head>
<title>Jeff Emmett</title>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🍄</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="Permissions-Policy" content="midi=*, microphone=*, camera=*, autoplay=*">
<link rel="preconnect" href="https://fonts.googleapis.com" />

39
package-lock.json generated
View File

@ -20,6 +20,7 @@
"@chengsokdara/use-whisper": "^0.2.0",
"@daily-co/daily-js": "^0.60.0",
"@daily-co/daily-react": "^0.20.0",
"@fal-ai/client": "^1.7.2",
"@mdxeditor/editor": "^3.51.0",
"@tldraw/assets": "^3.15.4",
"@tldraw/tldraw": "^3.15.4",
@ -1918,6 +1919,20 @@
"node": ">=18"
}
},
"node_modules/@fal-ai/client": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@fal-ai/client/-/client-1.7.2.tgz",
"integrity": "sha512-RZ1Qz2Kza4ExKPy2D+2UUWthNApe+oZe8D1Wcxqleyn4F344MOm8ibgqG2JSVmybEcJAD4q44078WYfb6Q9c6w==",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"eventsource-parser": "^1.1.2",
"robot3": "^0.4.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@fastify/busboy": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
@ -3505,6 +3520,15 @@
"react-dom": ">= 18 || >= 19"
}
},
"node_modules/@msgpack/msgpack": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.2.tgz",
"integrity": "sha512-JEW4DEtBzfe8HvUYecLU9e6+XJnKDlUAIve8FvPzF3Kzs6Xo/KuZkZJsDH0wJXl/qEZbeeE7edxDNY3kMs39hQ==",
"license": "ISC",
"engines": {
"node": ">= 18"
}
},
"node_modules/@multiformats/dns": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@multiformats/dns/-/dns-1.0.10.tgz",
@ -10098,6 +10122,15 @@
"node": ">=0.8.x"
}
},
"node_modules/eventsource-parser": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz",
"integrity": "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==",
"license": "MIT",
"engines": {
"node": ">=14.18"
}
},
"node_modules/exit-hook": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
@ -15807,6 +15840,12 @@
"node": ">= 0.8.15"
}
},
"node_modules/robot3": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/robot3/-/robot3-0.4.1.tgz",
"integrity": "sha512-hzjy826lrxzx8eRgv80idkf8ua1JAepRc9Efdtj03N3KNJuznQCPlyCJ7gnUmDFwZCLQjxy567mQVKmdv2BsXQ==",
"license": "BSD-2-Clause"
},
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",

View File

@ -37,6 +37,7 @@
"@chengsokdara/use-whisper": "^0.2.0",
"@daily-co/daily-js": "^0.60.0",
"@daily-co/daily-react": "^0.20.0",
"@fal-ai/client": "^1.7.2",
"@mdxeditor/editor": "^3.51.0",
"@tldraw/assets": "^3.15.4",
"@tldraw/tldraw": "^3.15.4",

View File

@ -23,7 +23,10 @@ const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) =
const [nfcStatus, setNfcStatus] = useState<'idle' | 'writing' | 'success' | 'error' | 'unsupported'>('idle');
const [nfcMessage, setNfcMessage] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const [inviteInput, setInviteInput] = useState('');
const [inviteStatus, setInviteStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const dropdownRef = useRef<HTMLDivElement>(null);
const dropdownMenuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null);
@ -58,7 +61,11 @@ const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) =
// Close dropdown when clicking outside or pressing ESC
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
const target = e.target as Node;
// Check if click is inside trigger OR the portal dropdown menu
const isInsideTrigger = dropdownRef.current && dropdownRef.current.contains(target);
const isInsideMenu = dropdownMenuRef.current && dropdownMenuRef.current.contains(target);
if (!isInsideTrigger && !isInsideMenu) {
setShowDropdown(false);
}
};
@ -89,6 +96,24 @@ const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) =
}
};
const handleInvite = async () => {
if (!inviteInput.trim()) return;
setInviteStatus('sending');
try {
// TODO: Implement actual invite API call
// For now, simulate sending invite
await new Promise(resolve => setTimeout(resolve, 1000));
setInviteStatus('sent');
setInviteInput('');
setTimeout(() => setInviteStatus('idle'), 3000);
} catch (err) {
console.error('Failed to send invite:', err);
setInviteStatus('error');
setTimeout(() => setInviteStatus('idle'), 3000);
}
};
const handleNfcWrite = async () => {
if (!('NDEFReader' in window)) {
setNfcStatus('unsupported');
@ -177,150 +202,243 @@ const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) =
{/* Dropdown - rendered via portal to break out of parent container */}
{showDropdown && dropdownPosition && createPortal(
<div
ref={dropdownMenuRef}
style={{
position: 'fixed',
top: dropdownPosition.top,
right: dropdownPosition.right,
width: '320px',
width: '340px',
background: 'var(--color-panel)',
backgroundColor: 'var(--color-panel)',
backdropFilter: 'none',
opacity: 1,
border: '1px solid var(--color-panel-contrast)',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
zIndex: 100000,
overflow: 'hidden',
pointerEvents: 'all',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
}}
onWheel={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
{/* Compact Header */}
<div style={{
padding: '12px 16px',
padding: '12px 14px',
borderBottom: '1px solid var(--color-panel-contrast)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
Invite to Board
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px' }}>👥</span> Share Board
</span>
<button
onClick={() => setShowDropdown(false)}
onPointerDown={(e) => e.stopPropagation()}
style={{
background: 'none',
background: 'var(--color-muted-2)',
border: 'none',
cursor: 'pointer',
padding: '4px',
padding: '4px 8px',
color: 'var(--color-text-3)',
fontSize: '18px',
fontSize: '11px',
fontFamily: 'inherit',
lineHeight: 1,
borderRadius: '4px',
}}
>
x
</button>
</div>
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* Board name */}
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* Invite by username/email */}
<div>
<div style={{
textAlign: 'center',
padding: '6px 10px',
backgroundColor: 'var(--color-muted-2)',
borderRadius: '6px',
display: 'flex',
gap: '8px',
}}>
<span style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>Board: </span>
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>{boardSlug}</span>
<input
type="text"
placeholder="Username or email..."
value={inviteInput}
onChange={(e) => setInviteInput(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter') handleInvite();
}}
onPointerDown={(e) => e.stopPropagation()}
onFocus={(e) => e.stopPropagation()}
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={handleInvite}
onPointerDown={(e) => e.stopPropagation()}
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,
transition: 'all 0.15s ease',
whiteSpace: 'nowrap',
}}
>
{inviteStatus === 'sending' ? '...' : inviteStatus === 'sent' ? '✓ Sent' : 'Invite'}
</button>
</div>
{inviteStatus === 'error' && (
<p style={{ fontSize: '11px', color: '#ef4444', marginTop: '4px' }}>
Failed to send invite. Please try again.
</p>
)}
</div>
{/* Permission selector */}
<div>
<span style={{ fontSize: '11px', color: 'var(--color-text-3)', fontWeight: 500, marginBottom: '6px', display: 'block' }}>Access Level</span>
{/* Divider with "or share link" */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
}}>
<div style={{ flex: 1, height: '1px', background: 'var(--color-panel-contrast)' }} />
<span style={{ fontSize: '11px', color: 'var(--color-text-3)', fontWeight: 500 }}>or share link</span>
<div style={{ flex: 1, height: '1px', background: 'var(--color-panel-contrast)' }} />
</div>
{/* Permission selector - pill style */}
<div style={{ display: 'flex', gap: '6px' }}>
{(['view', 'edit', 'admin'] as PermissionType[]).map((perm) => {
const isActive = permission === perm;
const { label, color } = PERMISSION_LABELS[perm];
const { label, description } = PERMISSION_LABELS[perm];
return (
<button
key={perm}
onClick={() => setPermission(perm)}
onPointerDown={(e) => e.stopPropagation()}
title={description}
style={{
flex: 1,
padding: '8px 6px',
border: isActive ? `2px solid ${color}` : '2px solid var(--color-panel-contrast)',
background: isActive ? `${color}15` : 'var(--color-panel)',
border: 'none',
background: isActive ? '#3b82f6' : 'var(--color-muted-2)',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: isActive ? 600 : 500,
color: isActive ? color : 'var(--color-text)',
fontSize: '11px',
fontWeight: 500,
fontFamily: 'inherit',
color: isActive ? 'white' : 'var(--color-text)',
transition: 'all 0.15s ease',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2px',
}}
>
{label}
<span>{label}</span>
<span style={{
fontSize: '9px',
fontWeight: 400,
opacity: 0.8,
color: isActive ? 'rgba(255,255,255,0.9)' : 'var(--color-text-3)',
}}>
{perm === 'view' ? 'Read only' : perm === 'edit' ? 'Can edit' : 'Full access'}
</span>
</button>
);
})}
</div>
</div>
{/* QR Code and URL */}
{/* QR Code and URL - larger and side by side */}
<div style={{
display: 'flex',
gap: '12px',
padding: '12px',
gap: '14px',
padding: '14px',
backgroundColor: 'var(--color-muted-2)',
borderRadius: '8px',
borderRadius: '10px',
}}>
{/* QR Code */}
{/* QR Code - larger */}
<div style={{
padding: '8px',
padding: '10px',
backgroundColor: 'white',
borderRadius: '6px',
borderRadius: '8px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<QRCodeSVG
value={getShareUrl()}
size={80}
size={100}
level="M"
includeMargin={false}
/>
</div>
{/* URL and Copy */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: '8px' }}>
{/* URL and Copy - stacked */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: '10px' }}>
<div style={{
padding: '8px',
padding: '10px 12px',
backgroundColor: 'var(--color-panel)',
borderRadius: '4px',
borderRadius: '6px',
border: '1px solid var(--color-panel-contrast)',
wordBreak: 'break-all',
fontSize: '10px',
fontSize: '11px',
fontFamily: 'monospace',
color: 'var(--color-text)',
maxHeight: '40px',
overflowY: 'auto',
lineHeight: 1.4,
}}>
{getShareUrl()}
</div>
<button
onClick={handleCopyUrl}
onPointerDown={(e) => e.stopPropagation()}
style={{
padding: '6px 12px',
padding: '8px 12px',
backgroundColor: copied ? '#10b981' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '4px',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '12px',
fontSize: '11px',
fontWeight: 500,
fontFamily: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
transition: 'background 0.15s',
gap: '6px',
transition: 'all 0.15s ease',
}}
>
{copied ? 'Copied!' : 'Copy Link'}
{copied ? (
<> Copied!</>
) : (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
Copy Link
</>
)}
</button>
</div>
</div>
@ -329,18 +447,31 @@ const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) =
<div>
<button
onClick={() => setShowAdvanced(!showAdvanced)}
onPointerDown={(e) => e.stopPropagation()}
style={{
width: '100%',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '11px',
fontFamily: 'inherit',
color: 'var(--color-text-3)',
padding: '4px 0',
padding: '6px 0',
display: 'flex',
alignItems: 'center',
gap: '4px',
justifyContent: 'center',
gap: '6px',
}}
>
<span style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '16px',
height: '16px',
borderRadius: '4px',
background: 'var(--color-muted-2)',
}}>
<svg
width="10"
height="10"
@ -350,7 +481,8 @@ const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) =
>
<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>
More options (NFC, Audio)
</span>
More options
</button>
{showAdvanced && (
@ -358,10 +490,12 @@ const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) =
{/* NFC Button */}
<button
onClick={handleNfcWrite}
onPointerDown={(e) => e.stopPropagation()}
disabled={nfcStatus === 'unsupported' || nfcStatus === 'writing'}
style={{
flex: 1,
padding: '10px',
fontFamily: 'inherit',
backgroundColor: nfcStatus === 'unsupported' ? 'var(--color-muted-2)' :
nfcStatus === 'success' ? '#d1fae5' :
nfcStatus === 'error' ? '#fee2e2' :
@ -390,9 +524,11 @@ const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) =
{/* Audio Button (coming soon) */}
<button
disabled
onPointerDown={(e) => e.stopPropagation()}
style={{
flex: 1,
padding: '10px',
fontFamily: 'inherit',
backgroundColor: 'var(--color-muted-2)',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '6px',

View File

@ -350,15 +350,18 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
right: dropdownPosition.right,
minWidth: '260px',
maxHeight: 'calc(100vh - 100px)',
background: 'var(--color-background)',
background: 'var(--color-background, #ffffff)',
backgroundColor: 'var(--color-background, #ffffff)',
border: '1px solid var(--color-grid)',
borderRadius: '8px',
boxShadow: '0 4px 16px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.05)',
boxShadow: '0 4px 16px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.08)',
zIndex: 100000,
overflowY: 'auto',
overflowX: 'hidden',
pointerEvents: 'all',
fontFamily: 'var(--tl-font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif)',
backdropFilter: 'none',
opacity: 1,
}}
onWheel={(e) => {
// Stop wheel events from propagating to canvas when over menu
@ -408,36 +411,44 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
</div>
{/* Quick actions */}
<div style={{ padding: '4px', borderBottom: '1px solid var(--color-grid)' }}>
<div style={{ padding: '8px', borderBottom: '1px solid var(--color-grid)' }}>
<a
href="/dashboard/"
onPointerDown={(e) => e.stopPropagation()}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
padding: '8px 10px',
padding: '10px 16px',
fontSize: '13px',
fontWeight: 500,
color: 'var(--color-text)',
fontWeight: 600,
color: 'white',
textDecoration: 'none',
transition: 'background 0.1s',
borderRadius: '4px',
transition: 'all 0.15s',
borderRadius: '6px',
pointerEvents: 'all',
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
boxShadow: '0 2px 4px rgba(99, 102, 241, 0.3)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-muted-2)';
e.currentTarget.style.background = 'linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)';
e.currentTarget.style.boxShadow = '0 4px 8px rgba(99, 102, 241, 0.4)';
e.currentTarget.style.transform = 'translateY(-1px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.background = 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)';
e.currentTarget.style.boxShadow = '0 2px 4px rgba(99, 102, 241, 0.3)';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="var(--color-text-2)" stroke="none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="white" stroke="none">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
My Saved Boards
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-2)" strokeWidth="2" style={{ marginLeft: 'auto' }}>
<polyline points="9 18 15 12 9 6"/>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="7" y1="17" x2="17" y2="7"/>
<polyline points="7 7 17 7 17 17"/>
</svg>
</a>
</div>
@ -455,8 +466,8 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
Integrations
</div>
{/* Google Workspace */}
<div style={{ padding: '6px 10px' }}>
{/* Google Workspace - Coming Soon */}
<div style={{ padding: '6px 10px', opacity: 0.6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<div style={{
width: '24px',
@ -477,113 +488,28 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
Google Workspace
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-2)' }}>
{googleConnected ? `${totalGoogleItems} items imported` : 'Not connected'}
Coming soon
</div>
</div>
{googleConnected && (
<span style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: '#22c55e',
}} />
)}
</div>
<div style={{ display: 'flex', gap: '6px' }}>
{googleConnected ? (
<>
<button
onClick={() => {
setShowGoogleBrowser(true);
setShowDropdown(false);
}}
onPointerDown={(e) => e.stopPropagation()}
disabled
style={{
flex: 1,
width: '100%',
padding: '6px 12px',
fontSize: '12px',
fontWeight: 600,
borderRadius: '4px',
border: 'none',
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
background: '#9ca3af',
color: 'white',
cursor: 'pointer',
pointerEvents: 'all',
transition: 'all 0.15s',
boxShadow: '0 2px 4px rgba(139, 92, 246, 0.3)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)';
e.currentTarget.style.boxShadow = '0 4px 8px rgba(139, 92, 246, 0.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)';
e.currentTarget.style.boxShadow = '0 2px 4px rgba(139, 92, 246, 0.3)';
cursor: 'not-allowed',
pointerEvents: 'none',
}}
>
Browse Data
Coming Soon
</button>
<button
onClick={handleGoogleDisconnect}
onPointerDown={(e) => e.stopPropagation()}
style={{
padding: '6px 12px',
fontSize: '12px',
fontWeight: 500,
borderRadius: '4px',
border: 'none',
background: '#6b7280',
color: 'white',
cursor: 'pointer',
pointerEvents: 'all',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#4b5563';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#6b7280';
}}
>
Disconnect
</button>
</>
) : (
<button
onClick={handleGoogleConnect}
onPointerDown={(e) => e.stopPropagation()}
disabled={googleLoading}
style={{
flex: 1,
padding: '6px 12px',
fontSize: '12px',
fontWeight: 600,
borderRadius: '4px',
border: 'none',
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
color: 'white',
cursor: googleLoading ? 'wait' : 'pointer',
opacity: googleLoading ? 0.7 : 1,
transition: 'all 0.15s',
pointerEvents: 'all',
boxShadow: '0 2px 4px rgba(139, 92, 246, 0.3)',
}}
onMouseEnter={(e) => {
if (!googleLoading) {
e.currentTarget.style.background = 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)';
e.currentTarget.style.boxShadow = '0 4px 8px rgba(139, 92, 246, 0.4)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)';
e.currentTarget.style.boxShadow = '0 2px 4px rgba(139, 92, 246, 0.3)';
}}
>
{googleLoading ? 'Connecting...' : 'Connect Google'}
</button>
)}
</div>
</div>
{/* Obsidian Vault */}
@ -861,8 +787,8 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
)}
</div>
{/* Miro Board Import */}
<div style={{ padding: '6px 10px', borderTop: '1px solid var(--color-grid)' }}>
{/* Miro Board Import - Coming Soon */}
<div style={{ padding: '6px 10px', borderTop: '1px solid var(--color-grid)', opacity: 0.6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<div style={{
width: '24px',
@ -884,24 +810,12 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
Miro Boards
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-2)' }}>
{isMiroApiKeyConfigured(session.username) ? 'API connected' : 'Import via JSON'}
Coming soon
</div>
</div>
{isMiroApiKeyConfigured(session.username) && (
<span style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: '#22c55e',
}} />
)}
</div>
<button
onClick={() => {
setShowMiroModal(true);
setShowDropdown(false);
}}
onPointerDown={(e) => e.stopPropagation()}
disabled
style={{
width: '100%',
padding: '6px 12px',
@ -909,35 +823,13 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
fontWeight: 600,
borderRadius: '4px',
border: 'none',
background: isMiroApiKeyConfigured(session.username)
? '#6b7280'
: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
background: '#9ca3af',
color: 'white',
cursor: 'pointer',
pointerEvents: 'all',
transition: 'all 0.15s',
boxShadow: isMiroApiKeyConfigured(session.username)
? 'none'
: '0 2px 4px rgba(139, 92, 246, 0.3)',
}}
onMouseEnter={(e) => {
if (isMiroApiKeyConfigured(session.username)) {
e.currentTarget.style.background = '#4b5563';
} else {
e.currentTarget.style.background = 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)';
e.currentTarget.style.boxShadow = '0 4px 8px rgba(139, 92, 246, 0.4)';
}
}}
onMouseLeave={(e) => {
if (isMiroApiKeyConfigured(session.username)) {
e.currentTarget.style.background = '#6b7280';
} else {
e.currentTarget.style.background = 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)';
e.currentTarget.style.boxShadow = '0 2px 4px rgba(139, 92, 246, 0.3)';
}
cursor: 'not-allowed',
pointerEvents: 'none',
}}
>
Import Miro Board
Coming Soon
</button>
</div>
</div>

View File

@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from 'react';
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode, useRef } from 'react';
import { Session, SessionError, PermissionLevel } from '../lib/auth/types';
import { AuthService } from '../lib/auth/authService';
import { saveSession, clearStoredSession } from '../lib/auth/sessionPersistence';
@ -41,6 +41,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const [session, setSessionState] = useState<Session>(initialSession);
const [accessToken, setAccessTokenState] = useState<string | null>(null);
// Track when auth state changes to bypass cache for a short period
// This prevents stale callbacks from using old cached permissions
const authChangedAtRef = useRef<number>(0);
// Extract access token from URL on mount
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
@ -105,6 +109,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const result = await AuthService.login(username);
if (result.success && result.session) {
// IMPORTANT: Mark auth as just changed - prevents stale callbacks from using cache
authChangedAtRef.current = Date.now();
// IMPORTANT: Clear permission cache when auth state changes
// This forces a fresh permission fetch with the new credentials
setSessionState({
@ -112,7 +119,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
boardPermissions: {},
currentBoardPermission: undefined,
});
console.log('🔐 Login successful - cleared permission cache');
console.log('🔐 Login successful - cleared permission cache, authChangedAt:', authChangedAtRef.current);
// Save session to localStorage if authenticated
if (result.session.authed && result.session.username) {
@ -148,6 +155,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const result = await AuthService.register(username);
if (result.success && result.session) {
// IMPORTANT: Mark auth as just changed - prevents stale callbacks from using cache
authChangedAtRef.current = Date.now();
// IMPORTANT: Clear permission cache when auth state changes
// This forces a fresh permission fetch with the new credentials
setSessionState({
@ -155,7 +165,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
boardPermissions: {},
currentBoardPermission: undefined,
});
console.log('🔐 Registration successful - cleared permission cache');
console.log('🔐 Registration successful - cleared permission cache, authChangedAt:', authChangedAtRef.current);
// Save session to localStorage if authenticated
if (result.session.authed && result.session.username) {
@ -185,6 +195,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
* Clear the current session
*/
const clearSession = useCallback((): void => {
// IMPORTANT: Mark auth as just changed - prevents stale callbacks from using cache
authChangedAtRef.current = Date.now();
clearStoredSession();
setSessionState({
username: '',
@ -197,6 +210,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
boardPermissions: {},
currentBoardPermission: undefined,
});
console.log('🔐 Session cleared - marked auth as changed, authChangedAt:', authChangedAtRef.current);
}, []);
/**
@ -230,8 +244,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
* Includes access token if available (from share link)
*/
const fetchBoardPermission = useCallback(async (boardId: string): Promise<PermissionLevel> => {
// Check cache first (but only if no access token - token changes permissions)
if (!accessToken && session.boardPermissions?.[boardId]) {
// IMPORTANT: Check if auth state changed recently (within last 5 seconds)
// If so, bypass cache entirely to prevent stale callbacks from returning old cached values
const authChangedRecently = Date.now() - authChangedAtRef.current < 5000;
if (authChangedRecently) {
console.log('🔐 Auth changed recently, bypassing permission cache');
}
// Check cache first (but only if no access token and auth didn't just change)
if (!accessToken && !authChangedRecently && session.boardPermissions?.[boardId]) {
console.log('🔐 Using cached permission for board:', boardId, session.boardPermissions[boardId]);
return session.boardPermissions[boardId];
}

View File

@ -1951,3 +1951,4 @@ html.dark button:not([class*="primary"]):not([style*="background"]) {
html.dark button:not([class*="primary"]):not([style*="background"]):hover {
background-color: var(--hover-bg);
}

364
src/hooks/useLiveImage.tsx Normal file
View File

@ -0,0 +1,364 @@
/**
* useLiveImage Hook
* Captures drawings within a frame shape and sends them to Fal.ai for AI enhancement
* Based on draw-fast implementation, adapted for canvas-website with Automerge sync
*/
import React, { createContext, useContext, useEffect, useRef, useCallback, useState } from 'react'
import { Editor, TLShapeId, Box, exportToBlob } from 'tldraw'
import { fal } from '@fal-ai/client'
// Fal.ai model endpoints
const FAL_MODEL_LCM = 'fal-ai/lcm-sd15-i2i' // Fast, real-time (~150ms)
const FAL_MODEL_FLUX_CANNY = 'fal-ai/flux-control-lora-canny/image-to-image' // Higher quality
interface LiveImageContextValue {
isConnected: boolean
apiKey: string | null
setApiKey: (key: string) => void
}
const LiveImageContext = createContext<LiveImageContextValue | null>(null)
interface LiveImageProviderProps {
children: React.ReactNode
apiKey?: string
}
/**
* Provider component that manages Fal.ai connection
*/
export function LiveImageProvider({ children, apiKey: initialApiKey }: LiveImageProviderProps) {
const [apiKey, setApiKeyState] = useState<string | null>(
initialApiKey || import.meta.env.VITE_FAL_API_KEY || null
)
const [isConnected, setIsConnected] = useState(false)
// Configure Fal.ai client when API key is available
useEffect(() => {
if (apiKey) {
fal.config({ credentials: apiKey })
setIsConnected(true)
console.log('LiveImage: Fal.ai client configured')
} else {
setIsConnected(false)
}
}, [apiKey])
const setApiKey = useCallback((key: string) => {
setApiKeyState(key)
// Also save to localStorage for persistence
localStorage.setItem('fal_api_key', key)
}, [])
// Try to load API key from localStorage on mount
useEffect(() => {
if (!apiKey) {
const storedKey = localStorage.getItem('fal_api_key')
if (storedKey) {
setApiKeyState(storedKey)
}
}
}, [])
return (
<LiveImageContext.Provider value={{ isConnected, apiKey, setApiKey }}>
{children}
</LiveImageContext.Provider>
)
}
export function useLiveImageContext() {
const context = useContext(LiveImageContext)
if (!context) {
throw new Error('useLiveImageContext must be used within a LiveImageProvider')
}
return context
}
interface UseLiveImageOptions {
editor: Editor
shapeId: TLShapeId
prompt: string
enabled?: boolean
throttleMs?: number
model?: 'lcm' | 'flux-canny'
strength?: number
onResult?: (imageUrl: string) => void
onError?: (error: Error) => void
}
interface LiveImageState {
isGenerating: boolean
lastGeneratedUrl: string | null
error: string | null
}
/**
* Hook that watches for drawing changes within a frame and generates AI images
*/
export function useLiveImage({
editor,
shapeId,
prompt,
enabled = true,
throttleMs = 500,
model = 'lcm',
strength = 0.65,
onResult,
onError,
}: UseLiveImageOptions): LiveImageState {
const [state, setState] = useState<LiveImageState>({
isGenerating: false,
lastGeneratedUrl: null,
error: null,
})
const requestVersionRef = useRef(0)
const lastRequestTimeRef = useRef(0)
const pendingRequestRef = useRef<NodeJS.Timeout | null>(null)
const context = useContext(LiveImageContext)
// Get shapes that intersect with this frame
const getChildShapes = useCallback(() => {
const shape = editor.getShape(shapeId)
if (!shape) return []
const bounds = editor.getShapePageBounds(shapeId)
if (!bounds) return []
// Find all shapes that intersect with this frame
const allShapes = editor.getCurrentPageShapes()
return allShapes.filter(s => {
if (s.id === shapeId) return false // Exclude the frame itself
const shapeBounds = editor.getShapePageBounds(s.id)
if (!shapeBounds) return false
return bounds.contains(shapeBounds) || bounds.collides(shapeBounds)
})
}, [editor, shapeId])
// Capture the drawing as a base64 image
const captureDrawing = useCallback(async (): Promise<string | null> => {
try {
const childShapes = getChildShapes()
if (childShapes.length === 0) return null
const shapeIds = childShapes.map(s => s.id)
// Export shapes to blob
const blob = await exportToBlob({
editor,
ids: shapeIds,
format: 'jpeg',
opts: {
background: true,
padding: 0,
scale: 1,
},
})
// Convert blob to data URL
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
} catch (error) {
console.error('LiveImage: Failed to capture drawing:', error)
return null
}
}, [editor, getChildShapes])
// Generate AI image from the sketch
const generateImage = useCallback(async () => {
if (!context?.isConnected || !enabled) {
console.log('LiveImage: Not connected or disabled')
return
}
const currentVersion = ++requestVersionRef.current
setState(prev => ({ ...prev, isGenerating: true, error: null }))
try {
const imageDataUrl = await captureDrawing()
if (!imageDataUrl) {
setState(prev => ({ ...prev, isGenerating: false }))
return
}
// Check if this request is still valid (not superseded by newer request)
if (currentVersion !== requestVersionRef.current) {
console.log('LiveImage: Request superseded, skipping')
return
}
const modelEndpoint = model === 'flux-canny' ? FAL_MODEL_FLUX_CANNY : FAL_MODEL_LCM
// Build the full prompt
const fullPrompt = prompt
? `${prompt}, hd, award-winning, impressive, detailed`
: 'hd, award-winning, impressive, detailed illustration'
console.log('LiveImage: Generating with prompt:', fullPrompt)
const result = await fal.subscribe(modelEndpoint, {
input: {
prompt: fullPrompt,
image_url: imageDataUrl,
strength: strength,
sync_mode: true,
seed: 42,
num_inference_steps: model === 'lcm' ? 4 : 20,
guidance_scale: model === 'lcm' ? 1 : 7.5,
enable_safety_checks: false,
},
pollInterval: 1000,
logs: true,
})
// Check if this result is still relevant
if (currentVersion !== requestVersionRef.current) {
console.log('LiveImage: Result from old request, discarding')
return
}
// Extract image URL from result
let imageUrl: string | null = null
if (result.data) {
const data = result.data as any
if (data.images && Array.isArray(data.images) && data.images.length > 0) {
imageUrl = data.images[0].url || data.images[0]
} else if (data.image) {
imageUrl = data.image.url || data.image
} else if (data.output) {
imageUrl = typeof data.output === 'string' ? data.output : data.output.url
}
}
if (imageUrl) {
console.log('LiveImage: Generated image:', imageUrl)
setState(prev => ({
...prev,
isGenerating: false,
lastGeneratedUrl: imageUrl,
error: null,
}))
onResult?.(imageUrl)
} else {
throw new Error('No image URL in response')
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
console.error('LiveImage: Generation failed:', errorMessage)
if (currentVersion === requestVersionRef.current) {
setState(prev => ({
...prev,
isGenerating: false,
error: errorMessage,
}))
onError?.(error instanceof Error ? error : new Error(errorMessage))
}
}
}, [context?.isConnected, enabled, captureDrawing, model, prompt, strength, onResult, onError])
// Throttled generation trigger
const triggerGeneration = useCallback(() => {
if (!enabled) return
const now = Date.now()
const timeSinceLastRequest = now - lastRequestTimeRef.current
// Clear any pending request
if (pendingRequestRef.current) {
clearTimeout(pendingRequestRef.current)
}
if (timeSinceLastRequest >= throttleMs) {
// Enough time has passed, generate immediately
lastRequestTimeRef.current = now
generateImage()
} else {
// Schedule generation after throttle period
const delay = throttleMs - timeSinceLastRequest
pendingRequestRef.current = setTimeout(() => {
lastRequestTimeRef.current = Date.now()
generateImage()
}, delay)
}
}, [enabled, throttleMs, generateImage])
// Watch for changes to shapes within the frame
useEffect(() => {
if (!enabled) return
const handleChange = () => {
triggerGeneration()
}
// Subscribe to store changes
const unsubscribe = editor.store.listen(handleChange, {
source: 'user',
scope: 'document',
})
return () => {
unsubscribe()
if (pendingRequestRef.current) {
clearTimeout(pendingRequestRef.current)
}
}
}, [editor, enabled, triggerGeneration])
return state
}
/**
* Convert SVG string to JPEG data URL (fast method)
*/
async function svgToJpegDataUrl(
svgString: string,
width: number,
height: number,
quality: number = 0.3
): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image()
const svgBlob = new Blob([svgString], { type: 'image/svg+xml' })
const url = URL.createObjectURL(svgBlob)
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('Failed to get canvas context'))
return
}
// Fill with white background
ctx.fillStyle = 'white'
ctx.fillRect(0, 0, width, height)
// Draw the SVG
ctx.drawImage(img, 0, 0, width, height)
// Convert to JPEG
const dataUrl = canvas.toDataURL('image/jpeg', quality)
URL.revokeObjectURL(url)
resolve(dataUrl)
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('Failed to load SVG'))
}
img.src = url
})
}

View File

@ -118,6 +118,32 @@ export const TOOL_SCHEMAS: Record<string, ToolSchema> = {
externalServices: ['RunPod GPU (Wan2.1)'],
},
Drawfast: {
id: 'Drawfast',
displayName: 'Drawfast',
primaryColor: '#06b6d4',
icon: '✏️',
purpose: 'Real-time AI-assisted sketching and drawing',
description: 'An AI drawing frame where you sketch using tldraw tools and AI enhances your drawings in real-time. Uses Fal.ai LCM for fast sketch-to-image generation. Draw inside the frame, then click Generate or enable real-time mode.',
capabilities: [
{ name: 'Real-time AI Enhancement', description: 'AI interprets and enhances sketches as you draw' },
{ name: 'Sketch-to-Image', description: 'Transform rough sketches into polished visuals via Fal.ai' },
{ name: 'Native tldraw Integration', description: 'Draw using pencil, pen, and other tldraw tools' },
{ name: 'Strength Control', description: 'Adjust how much AI transforms your sketch (10-90%)' },
{ name: 'Overlay/Side-by-side', description: 'View AI result overlaid on sketch or side-by-side' },
],
useCases: [
'Rapid concept sketching with AI assistance',
'Visual brainstorming and ideation',
'Transforming rough ideas into visuals',
'Creating quick mockups and wireframes',
'Exploring creative directions with AI',
],
tags: ['ai', 'sketch', 'drawing', 'creative', 'real-time', 'interactive'],
requiresExternalServices: true,
externalServices: ['Fal.ai (LCM model)'],
},
// === Content & Notes Tools ===
ChatBox: {
@ -472,6 +498,11 @@ export function suggestToolsForIntent(intent: string): ToolSchema[] {
suggestions.push(TOOL_SCHEMAS.VideoGen)
}
// Drawfast / AI Sketching intents
if (intentLower.match(/\b(drawfast|sketch|doodle|freehand|whiteboard|draw.*ai|ai.*draw|quick.*draw|rapid.*sketch|scribble|hand.*draw)\b/)) {
suggestions.push(TOOL_SCHEMAS.Drawfast)
}
// Chat/Conversation intents
if (intentLower.match(/\b(chat|conversation|discuss|dialogue|talk|multi-turn|back.?and.?forth|iterative|deep.?dive|explore.?topic|q.?&.?a)\b/)) {
suggestions.push(TOOL_SCHEMAS.ChatBox)
@ -530,7 +561,7 @@ export function suggestToolsForIntent(intent: string): ToolSchema[] {
// Creative work
if (intentLower.match(/\b(creative|artistic|design|mood.?board|inspiration|concept|prototype|mockup)\b/)) {
suggestions.push(TOOL_SCHEMAS.ImageGen, TOOL_SCHEMAS.Prompt, TOOL_SCHEMAS.Markdown)
suggestions.push(TOOL_SCHEMAS.ImageGen, TOOL_SCHEMAS.Drawfast, TOOL_SCHEMAS.Prompt, TOOL_SCHEMAS.Markdown)
}
// Meeting / Collaboration

View File

@ -0,0 +1,638 @@
/**
* Drawfast Shape - AI-Enhanced Sketch Frame
* A drawing frame that captures sketches and generates AI-enhanced versions in real-time
* Based on draw-fast/tldraw implementation, adapted for canvas-website with Automerge sync
*/
import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw"
import { useState, useEffect } from "react"
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
import { usePinnedToView } from "../hooks/usePinnedToView"
import { useMaximize } from "../hooks/useMaximize"
import { useLiveImage, useLiveImageContext } from "../hooks/useLiveImage"
export type IDrawfastShape = TLBaseShape<
"Drawfast",
{
w: number
h: number
prompt: string
generatedImageUrl: string | null
overlayMode: boolean // true = overlay result on sketch, false = side by side
isGenerating: boolean
autoGenerate: boolean // true = real-time, false = manual button
strength: number // 0-1, how much to transform the sketch
pinnedToView: boolean
tags: string[]
}
>
export class DrawfastShape extends BaseBoxShapeUtil<IDrawfastShape> {
static override type = "Drawfast" as const
// Drawfast theme color: Cyan (AI/Creative)
static readonly PRIMARY_COLOR = "#06b6d4"
getDefaultProps(): IDrawfastShape["props"] {
return {
w: 512,
h: 512,
prompt: "",
generatedImageUrl: null,
overlayMode: true,
isGenerating: false,
autoGenerate: false, // Start with manual mode for easier debugging
strength: 0.65,
pinnedToView: false,
tags: ['ai', 'sketch', 'drawing'],
}
}
// Lock aspect ratio for consistent AI generation
override isAspectRatioLocked = () => true
indicator(shape: IDrawfastShape) {
return (
<rect
x={0}
y={0}
width={shape.props.w}
height={shape.props.h}
fill="none"
stroke={DrawfastShape.PRIMARY_COLOR}
strokeWidth={2}
strokeDasharray="8 4"
/>
)
}
component(shape: IDrawfastShape) {
const editor = this.editor
const [isMinimized, setIsMinimized] = useState(false)
const [localPrompt, setLocalPrompt] = useState(shape.props.prompt)
const isSelected = editor.getSelectedShapeIds().includes(shape.id)
// Check if Fal.ai is configured
let liveImageContext: ReturnType<typeof useLiveImageContext> | null = null
try {
liveImageContext = useLiveImageContext()
} catch {
// Provider not available, will show setup UI
}
// Use pinning hook
usePinnedToView(editor, shape.id, shape.props.pinnedToView)
// Use maximize hook
const { isMaximized, toggleMaximize } = useMaximize({
editor: editor,
shapeId: shape.id,
currentW: shape.props.w,
currentH: shape.props.h,
shapeType: 'Drawfast',
})
// Use live image generation (only when auto-generate is on)
const liveImageState = useLiveImage({
editor,
shapeId: shape.id,
prompt: shape.props.prompt,
enabled: shape.props.autoGenerate && !!liveImageContext?.isConnected,
throttleMs: 500,
model: 'lcm',
strength: shape.props.strength,
onResult: (imageUrl) => {
editor.updateShape<IDrawfastShape>({
id: shape.id,
type: 'Drawfast',
props: {
generatedImageUrl: imageUrl,
isGenerating: false,
},
})
},
onError: (error) => {
console.error('Drawfast generation error:', error)
editor.updateShape<IDrawfastShape>({
id: shape.id,
type: 'Drawfast',
props: {
isGenerating: false,
},
})
},
})
// Sync local prompt with shape prop
useEffect(() => {
setLocalPrompt(shape.props.prompt)
}, [shape.props.prompt])
const handleClose = () => {
editor.deleteShape(shape.id)
}
const handleMinimize = () => {
setIsMinimized(!isMinimized)
}
const handlePinToggle = () => {
editor.updateShape<IDrawfastShape>({
id: shape.id,
type: shape.type,
props: {
pinnedToView: !shape.props.pinnedToView,
},
})
}
const handlePromptChange = (newPrompt: string) => {
setLocalPrompt(newPrompt)
}
const handlePromptSubmit = () => {
editor.updateShape<IDrawfastShape>({
id: shape.id,
type: 'Drawfast',
props: {
prompt: localPrompt,
},
})
}
const handleToggleOverlay = () => {
editor.updateShape<IDrawfastShape>({
id: shape.id,
type: 'Drawfast',
props: {
overlayMode: !shape.props.overlayMode,
},
})
}
const handleToggleAutoGenerate = () => {
editor.updateShape<IDrawfastShape>({
id: shape.id,
type: 'Drawfast',
props: {
autoGenerate: !shape.props.autoGenerate,
},
})
}
const handleManualGenerate = async () => {
if (!liveImageContext?.isConnected) {
alert('Please configure your Fal.ai API key first')
return
}
editor.updateShape<IDrawfastShape>({
id: shape.id,
type: 'Drawfast',
props: {
isGenerating: true,
},
})
// The useLiveImage hook will handle the generation when we trigger it
// For manual mode, we'll call the generation directly
try {
const { fal } = await import('@fal-ai/client')
// Get shapes inside this frame
const bounds = editor.getShapePageBounds(shape.id)
if (!bounds) return
const allShapes = editor.getCurrentPageShapes()
const childShapes = allShapes.filter(s => {
if (s.id === shape.id) return false
const shapeBounds = editor.getShapePageBounds(s.id)
if (!shapeBounds) return false
return bounds.contains(shapeBounds) || bounds.collides(shapeBounds)
})
if (childShapes.length === 0) {
console.log('Drawfast: No shapes to capture')
editor.updateShape<IDrawfastShape>({
id: shape.id,
type: 'Drawfast',
props: { isGenerating: false },
})
return
}
// Export shapes to blob
const { exportToBlob } = await import('tldraw')
const blob = await exportToBlob({
editor,
ids: childShapes.map(s => s.id),
format: 'jpeg',
opts: {
background: true,
padding: 0,
scale: 1,
},
})
// Convert to data URL
const imageDataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
const fullPrompt = shape.props.prompt
? `${shape.props.prompt}, hd, award-winning, impressive, detailed`
: 'hd, award-winning, impressive, detailed illustration'
console.log('Drawfast: Generating with prompt:', fullPrompt)
const result = await fal.subscribe('fal-ai/lcm-sd15-i2i', {
input: {
prompt: fullPrompt,
image_url: imageDataUrl,
strength: shape.props.strength,
sync_mode: true,
seed: 42,
num_inference_steps: 4,
guidance_scale: 1,
enable_safety_checks: false,
},
pollInterval: 1000,
logs: true,
})
// Extract image URL
let imageUrl: string | null = null
const data = result.data as any
if (data?.images?.[0]?.url) {
imageUrl = data.images[0].url
} else if (data?.images?.[0]) {
imageUrl = data.images[0]
} else if (data?.image?.url) {
imageUrl = data.image.url
} else if (data?.image) {
imageUrl = data.image
}
if (imageUrl) {
console.log('Drawfast: Generated image:', imageUrl)
editor.updateShape<IDrawfastShape>({
id: shape.id,
type: 'Drawfast',
props: {
generatedImageUrl: imageUrl,
isGenerating: false,
},
})
} else {
throw new Error('No image URL in response')
}
} catch (error) {
console.error('Drawfast generation error:', error)
editor.updateShape<IDrawfastShape>({
id: shape.id,
type: 'Drawfast',
props: { isGenerating: false },
})
}
}
const handleStrengthChange = (newStrength: number) => {
editor.updateShape<IDrawfastShape>({
id: shape.id,
type: 'Drawfast',
props: {
strength: newStrength,
},
})
}
// Custom header content
const headerContent = (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flex: 1 }}>
<span style={{ fontSize: '14px' }}></span>
<span style={{ fontSize: '13px', fontWeight: 600 }}>Drawfast</span>
{(shape.props.isGenerating || liveImageState.isGenerating) && (
<span style={{
fontSize: '10px',
color: DrawfastShape.PRIMARY_COLOR,
animation: 'pulse 1.5s ease-in-out infinite',
}}>
Generating...
</span>
)}
</div>
)
// Show API key setup if not configured
const showApiKeySetup = !liveImageContext?.isConnected
return (
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
<StandardizedToolWrapper
title="Drawfast"
headerContent={headerContent}
primaryColor={DrawfastShape.PRIMARY_COLOR}
isSelected={isSelected}
width={shape.props.w}
height={shape.props.h}
onClose={handleClose}
onMinimize={handleMinimize}
isMinimized={isMinimized}
onMaximize={toggleMaximize}
isMaximized={isMaximized}
editor={editor}
shapeId={shape.id}
isPinnedToView={shape.props.pinnedToView}
onPinToggle={handlePinToggle}
tags={shape.props.tags}
onTagsChange={(newTags) => {
editor.updateShape<IDrawfastShape>({
id: shape.id,
type: 'Drawfast',
props: { tags: newTags },
})
}}
tagsEditable={true}
>
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#1a1a2e',
overflow: 'hidden',
}}>
{/* Drawing Area / Result Display */}
<div style={{
flex: 1,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fff',
overflow: 'hidden',
}}>
{/* Generated Image (if available and overlay mode) */}
{shape.props.generatedImageUrl && shape.props.overlayMode && (
<img
src={shape.props.generatedImageUrl}
alt="AI Generated"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
pointerEvents: 'none',
opacity: 0.9,
zIndex: 10,
}}
/>
)}
{/* Instructions when empty */}
{!shape.props.generatedImageUrl && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
color: '#666',
fontSize: '14px',
padding: '20px',
pointerEvents: 'none',
zIndex: 5,
}}>
<div style={{ fontSize: '32px', marginBottom: '8px' }}></div>
<div>Draw inside this frame</div>
<div style={{ fontSize: '12px', marginTop: '4px', color: '#999' }}>
Use the pencil, pen, or other tools to sketch
</div>
</div>
)}
{/* Loading indicator */}
{(shape.props.isGenerating || liveImageState.isGenerating) && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 20,
backgroundColor: 'rgba(0,0,0,0.7)',
padding: '16px 24px',
borderRadius: '8px',
color: 'white',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}>
<div style={{
width: 24,
height: 24,
border: '3px solid rgba(255,255,255,0.3)',
borderTopColor: DrawfastShape.PRIMARY_COLOR,
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
Generating...
</div>
)}
</div>
{/* Side-by-side result (when not overlay mode) */}
{shape.props.generatedImageUrl && !shape.props.overlayMode && (
<div style={{
height: '40%',
borderTop: '2px solid #333',
backgroundColor: '#111',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<img
src={shape.props.generatedImageUrl}
alt="AI Generated"
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
}}
/>
</div>
)}
{/* Controls */}
<div style={{
padding: '8px 12px',
backgroundColor: '#1a1a2e',
borderTop: '1px solid #333',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}>
{/* API Key Setup */}
{showApiKeySetup && (
<div style={{
padding: '8px',
backgroundColor: '#2a2a3e',
borderRadius: '4px',
fontSize: '12px',
color: '#aaa',
}}>
<div style={{ marginBottom: '4px', color: '#ff9500' }}>
Fal.ai API key not configured
</div>
<input
type="password"
placeholder="Enter FAL_KEY..."
style={{
width: '100%',
padding: '6px 8px',
borderRadius: '4px',
border: '1px solid #444',
backgroundColor: '#1a1a2e',
color: '#fff',
fontSize: '12px',
}}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') {
const value = (e.target as HTMLInputElement).value
if (value && liveImageContext) {
liveImageContext.setApiKey(value)
}
}
}}
onPointerDown={(e) => e.stopPropagation()}
/>
<div style={{ marginTop: '4px', fontSize: '10px' }}>
Get your key at <a href="https://fal.ai" target="_blank" style={{ color: DrawfastShape.PRIMARY_COLOR }}>fal.ai</a>
</div>
</div>
)}
{/* Prompt Input */}
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={localPrompt}
onChange={(e) => handlePromptChange(e.target.value)}
onBlur={handlePromptSubmit}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') {
handlePromptSubmit()
if (!shape.props.autoGenerate) {
handleManualGenerate()
}
}
}}
onPointerDown={(e) => e.stopPropagation()}
placeholder="Describe the style..."
style={{
flex: 1,
padding: '8px 10px',
borderRadius: '4px',
border: '1px solid #444',
backgroundColor: '#2a2a3e',
color: '#fff',
fontSize: '12px',
}}
/>
{!shape.props.autoGenerate && (
<button
onClick={handleManualGenerate}
onPointerDown={(e) => e.stopPropagation()}
disabled={shape.props.isGenerating || !liveImageContext?.isConnected}
style={{
padding: '8px 16px',
borderRadius: '4px',
border: 'none',
backgroundColor: shape.props.isGenerating ? '#444' : DrawfastShape.PRIMARY_COLOR,
color: '#fff',
fontSize: '12px',
fontWeight: 600,
cursor: shape.props.isGenerating ? 'not-allowed' : 'pointer',
}}
>
{shape.props.isGenerating ? '...' : '✨ Generate'}
</button>
)}
</div>
{/* Settings Row */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
fontSize: '11px',
color: '#888',
}}>
{/* Strength Slider */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span>Strength:</span>
<input
type="range"
min="0.1"
max="0.9"
step="0.05"
value={shape.props.strength}
onChange={(e) => handleStrengthChange(parseFloat(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
style={{ width: '60px', accentColor: DrawfastShape.PRIMARY_COLOR }}
/>
<span>{Math.round(shape.props.strength * 100)}%</span>
</div>
{/* Auto-generate toggle */}
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={shape.props.autoGenerate}
onChange={handleToggleAutoGenerate}
onPointerDown={(e) => e.stopPropagation()}
style={{ accentColor: DrawfastShape.PRIMARY_COLOR }}
/>
Real-time
</label>
{/* Overlay toggle */}
<button
onClick={handleToggleOverlay}
onPointerDown={(e) => e.stopPropagation()}
style={{
padding: '4px 8px',
borderRadius: '4px',
border: '1px solid #444',
backgroundColor: shape.props.overlayMode ? DrawfastShape.PRIMARY_COLOR : '#2a2a3e',
color: '#fff',
fontSize: '10px',
cursor: 'pointer',
}}
>
{shape.props.overlayMode ? 'Overlay' : 'Side-by-side'}
</button>
</div>
</div>
</div>
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
`}</style>
</StandardizedToolWrapper>
</HTMLContainer>
)
}
}

11
src/tools/DrawfastTool.ts Normal file
View File

@ -0,0 +1,11 @@
import { BaseBoxShapeTool, TLEventHandlers } from "tldraw"
export class DrawfastTool extends BaseBoxShapeTool {
static override id = "Drawfast"
shapeType = "Drawfast"
override initial = "idle"
override onComplete: TLEventHandlers["onComplete"] = () => {
this.editor.setCurrentTool('select')
}
}

View File

@ -30,11 +30,11 @@ export function CommandPalette() {
{ id: 'Prompt', label: 'LLM Prompt', kbd: '⌃⇧L', key: 'L', icon: '🤖', category: 'tool' },
{ id: 'ObsidianNote', label: 'Obsidian Note', kbd: '⌃⇧O', key: 'O', icon: '📓', category: 'tool' },
{ id: 'Transcription', label: 'Transcription', kbd: '⌃⇧T', key: 'T', icon: '🎤', category: 'tool' },
{ id: 'Holon', label: 'Holon', kbd: '⌃⇧H', key: 'H', icon: '⭕', category: 'tool' },
// { id: 'Holon', label: 'Holon', kbd: '⌃⇧H', key: 'H', icon: '⭕', category: 'tool' }, // Temporarily hidden
{ id: 'FathomMeetings', label: 'Fathom Meetings', kbd: '⌃⇧F', key: 'F', icon: '📅', category: 'tool' },
{ id: 'ImageGen', label: 'Image Gen', kbd: '⌃⇧I', key: 'I', icon: '🖼️', category: 'tool' },
{ id: 'VideoGen', label: 'Video Gen', kbd: '⌃⇧G', key: 'G', icon: '🎬', category: 'tool' },
{ id: 'Multmux', label: 'Terminal', kbd: '⌃⇧K', key: 'K', icon: '💻', category: 'tool' },
// { id: 'VideoGen', label: 'Video Gen', kbd: '⌃⇧G', key: 'G', icon: '🎬', category: 'tool' }, // Temporarily hidden
// { id: 'Multmux', label: 'Terminal', kbd: '⌃⇧K', key: 'K', icon: '💻', category: 'tool' }, // Temporarily hidden
]
// Custom actions with shortcuts (matching overrides.tsx)

View File

@ -232,14 +232,25 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
<TldrawUiMenuItem {...tools.Prompt} />
<TldrawUiMenuItem {...tools.ChatBox} />
<TldrawUiMenuItem {...tools.ImageGen} />
{/* VideoGen - temporarily hidden until in better working state
<TldrawUiMenuItem {...tools.VideoGen} />
*/}
{/* Drawfast - temporarily hidden until in better working state
<TldrawUiMenuItem {...tools.Drawfast} />
*/}
<TldrawUiMenuItem {...tools.Markdown} />
<TldrawUiMenuItem {...tools.ObsidianNote} />
<TldrawUiMenuItem {...tools.Transcription} />
<TldrawUiMenuItem {...tools.Embed} />
{/* Holon - temporarily hidden until in better working state
<TldrawUiMenuItem {...tools.Holon} />
*/}
{/* Terminal (Multmux) - temporarily hidden until in better working state
<TldrawUiMenuItem {...tools.Multmux} />
*/}
{/* Map - temporarily hidden until in better working state
<TldrawUiMenuItem {...tools.Map} />
*/}
<TldrawUiMenuItem {...tools.SlideShape} />
<TldrawUiMenuItem {...tools.VideoChat} />
<TldrawUiMenuItem {...tools.FathomMeetings} />

View File

@ -726,6 +726,7 @@ export function CustomToolbar() {
isSelected={tools["Transcription"].id === editor.getCurrentToolId()}
/>
)}
{/* Holon - temporarily hidden until in better working state
{tools["Holon"] && (
<TldrawUiMenuItem
{...tools["Holon"]}
@ -734,6 +735,7 @@ export function CustomToolbar() {
isSelected={tools["Holon"].id === editor.getCurrentToolId()}
/>
)}
*/}
{tools["FathomMeetings"] && (
<TldrawUiMenuItem
{...tools["FathomMeetings"]}
@ -750,6 +752,7 @@ export function CustomToolbar() {
isSelected={tools["ImageGen"].id === editor.getCurrentToolId()}
/>
)}
{/* VideoGen - temporarily hidden until in better working state
{tools["VideoGen"] && (
<TldrawUiMenuItem
{...tools["VideoGen"]}
@ -758,6 +761,8 @@ export function CustomToolbar() {
isSelected={tools["VideoGen"].id === editor.getCurrentToolId()}
/>
)}
*/}
{/* Terminal (Multmux) - temporarily hidden until in better working state
{tools["Multmux"] && (
<TldrawUiMenuItem
{...tools["Multmux"]}
@ -766,6 +771,8 @@ export function CustomToolbar() {
isSelected={tools["Multmux"].id === editor.getCurrentToolId()}
/>
)}
*/}
{/* Map - temporarily hidden until in better working state
{tools["Map"] && (
<TldrawUiMenuItem
{...tools["Map"]}
@ -774,6 +781,7 @@ export function CustomToolbar() {
isSelected={tools["Map"].id === editor.getCurrentToolId()}
/>
)}
*/}
{/* Refresh All ObsNotes Button */}
{(() => {
const allShapes = editor.getCurrentPageShapes()

View File

@ -473,19 +473,18 @@ type FollowUpContext =
| { type: 'selection'; count: number; shapeTypes: Record<string, number> }
// Follow-up suggestions after transform commands
// NOTE: Arrow/connection drawing is not yet implemented, so those suggestions are removed
const TRANSFORM_FOLLOWUPS: Record<string, FollowUpSuggestion[]> = {
// After arranging in a row
'arrange-row': [
{ label: 'make same size', prompt: 'make these the same size', icon: '📐', category: 'refine' },
{ label: 'add labels', prompt: 'add a label above each shape', icon: '🏷️', category: 'expand' },
{ label: 'connect with arrows', prompt: 'draw arrows connecting these in sequence', icon: '→', category: 'connect' },
{ label: 'group these', prompt: 'create a frame around these shapes', icon: '📦', category: 'organize' },
],
// After arranging in a column
'arrange-column': [
{ label: 'make same width', prompt: 'make these the same width', icon: '↔️', category: 'refine' },
{ label: 'number them', prompt: 'add numbers before each item', icon: '🔢', category: 'expand' },
{ label: 'connect vertically', prompt: 'draw arrows connecting these from top to bottom', icon: '↓', category: 'connect' },
{ label: 'add header', prompt: 'create a title above this column', icon: '📝', category: 'expand' },
],
// After arranging in a grid
@ -498,7 +497,6 @@ const TRANSFORM_FOLLOWUPS: Record<string, FollowUpSuggestion[]> = {
// After arranging in a circle
'arrange-circle': [
{ label: 'add center node', prompt: 'add a central connecting node', icon: '⭕', category: 'expand' },
{ label: 'connect to center', prompt: 'draw lines from each to the center', icon: '🕸️', category: 'connect' },
{ label: 'label the cycle', prompt: 'add a title for this cycle diagram', icon: '📝', category: 'expand' },
],
// After aligning
@ -522,7 +520,6 @@ const TRANSFORM_FOLLOWUPS: Record<string, FollowUpSuggestion[]> = {
'distribute-horizontal': [
{ label: 'align tops', prompt: 'align these to the top', icon: '⬆️', category: 'refine' },
{ label: 'make same size', prompt: 'make these the same size', icon: '📐', category: 'refine' },
{ label: 'connect in sequence', prompt: 'draw arrows between these', icon: '→', category: 'connect' },
],
'distribute-vertical': [
{ label: 'align left', prompt: 'align these to the left', icon: '⬅️', category: 'refine' },
@ -548,8 +545,8 @@ const TRANSFORM_FOLLOWUPS: Record<string, FollowUpSuggestion[]> = {
// After semantic clustering
'cluster-semantic': [
{ label: 'label clusters', prompt: 'add a label to each cluster', icon: '🏷️', category: 'expand' },
{ label: 'connect related', prompt: 'draw connections between related clusters', icon: '🔗', category: 'connect' },
{ label: 'create overview', prompt: 'create a summary of all clusters', icon: '📊', category: 'expand' },
{ label: 'color by group', prompt: 'color code each cluster differently', icon: '🎨', category: 'refine' },
],
}
@ -580,6 +577,7 @@ const TOOL_SPAWN_FOLLOWUPS: Record<string, FollowUpSuggestion[]> = {
}
// Generic follow-ups based on canvas state
// NOTE: Connection/arrow drawing is not yet implemented, so we use different suggestions
const CANVAS_STATE_FOLLOWUPS = {
manyShapes: [
{ label: 'organize all', prompt: 'help me organize everything on this canvas', icon: '🗂️', category: 'organize' as const },
@ -588,7 +586,7 @@ const CANVAS_STATE_FOLLOWUPS = {
],
hasText: [
{ label: 'summarize all', prompt: 'create a summary of all text content', icon: '📝', category: 'organize' as const },
{ label: 'find connections', prompt: 'what connections exist between my notes?', icon: '🔗', category: 'connect' as const },
{ label: 'find themes', prompt: 'what themes exist across my notes?', icon: '🎯', category: 'expand' as const },
],
hasImages: [
{ label: 'describe images', prompt: 'what themes are in my images?', icon: '🖼️', category: 'expand' as const },
@ -1426,7 +1424,7 @@ export function MycelialIntelligenceBar() {
}
}, [editor, suggestedTools, spawnedToolIds])
// Responsive layout - detect window width
// Responsive layout - detect window width and calculate available space
const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1024)
const isMobile = windowWidth < 640
const isNarrow = windowWidth < 768
@ -1437,13 +1435,23 @@ export function MycelialIntelligenceBar() {
return () => window.removeEventListener('resize', handleResize)
}, [])
// Calculate available width between left and right menus
// Left menu (hamburger, page menu): ~140px
// Right menu (share, CryptID, settings): ~280px
// Add padding: 20px on each side
const leftMenuWidth = 140
const rightMenuWidth = 280
const menuPadding = 40 // 20px padding on each side
const availableWidth = windowWidth - leftMenuWidth - rightMenuWidth - menuPadding
const maxBarWidth = Math.max(200, Math.min(520, availableWidth)) // Clamp between 200-520px
// Height: taller when showing suggestion chips (single tool or 2+ selected)
// Base height matches the top-right menu (~40px) for visual alignment
const showSuggestions = selectedToolInfo || (selectionInfo && selectionInfo.count > 1)
const collapsedHeight = showSuggestions ? 68 : 40
const maxExpandedHeight = isMobile ? 300 : 400
// Responsive width: full width on mobile, percentage on narrow, fixed on desktop
const barWidth = isMobile ? 'calc(100% - 20px)' : isNarrow ? 'calc(100% - 120px)' : 520
// Responsive width: dynamically sized to fit between left and right menus
const barWidth = isMobile ? 'calc(100% - 20px)' : maxBarWidth
// Calculate dynamic height when expanded based on content
// Header: ~45px, Input area: ~56px, padding: ~24px = ~125px fixed
@ -1466,7 +1474,7 @@ export function MycelialIntelligenceBar() {
left: '50%',
transform: 'translateX(-50%)',
width: barWidth,
maxWidth: isMobile ? 'none' : '520px',
maxWidth: isMobile ? 'none' : `${maxBarWidth}px`,
height: isExpanded ? 'auto' : collapsedHeight,
minHeight: isExpanded ? minExpandedHeight : collapsedHeight,
maxHeight: isExpanded ? maxExpandedHeight : collapsedHeight,

View File

@ -12,6 +12,7 @@ import CryptIDDropdown from "../components/auth/CryptIDDropdown"
import StarBoardButton from "../components/StarBoardButton"
import ShareBoardButton from "../components/ShareBoardButton"
import { SettingsDialog } from "./SettingsDialog"
// 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"
@ -54,6 +55,7 @@ function CustomSharePanel() {
const [showShortcuts, setShowShortcuts] = React.useState(false)
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')
@ -228,8 +230,8 @@ function CustomSharePanel() {
}
})
// Custom tools
const customToolIds = ['VideoChat', 'ChatBox', 'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'Prompt', 'ObsidianNote', 'Transcription', 'Holon', 'FathomMeetings', 'ImageGen', 'VideoGen', 'Multmux']
// Custom tools (VideoGen and Map temporarily hidden)
const customToolIds = ['VideoChat', 'ChatBox', 'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'Prompt', 'ObsidianNote', 'Transcription', 'Holon', 'FathomMeetings', 'ImageGen', 'Multmux']
customToolIds.forEach(toolId => {
const tool = tools[toolId]
if (tool?.kbd) {
@ -299,9 +301,12 @@ function CustomSharePanel() {
display: 'flex',
alignItems: 'center',
gap: '0',
background: 'var(--color-muted-1)',
background: isDarkMode ? '#2d2d2d' : '#f3f4f6',
backgroundColor: isDarkMode ? '#2d2d2d' : '#f3f4f6',
backdropFilter: 'none',
opacity: 1,
borderRadius: '20px',
border: '1px solid var(--color-panel-contrast)',
border: `1px solid ${isDarkMode ? '#404040' : '#e5e7eb'}`,
padding: '4px 6px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}>
@ -383,47 +388,67 @@ function CustomSharePanel() {
position: 'fixed',
top: settingsDropdownPos.top,
right: settingsDropdownPos.right,
minWidth: '200px',
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: '0 4px 20px rgba(0,0,0,0.2)',
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 Display */}
<div style={{ padding: '10px 16px' }}>
{/* Board Permission Section */}
<div style={{ padding: '12px 16px 16px' }}>
{/* Section Header */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px',
gap: '8px',
marginBottom: '12px',
paddingBottom: '8px',
borderBottom: '1px solid var(--color-panel-contrast)',
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: '10px', fontSize: '13px', color: 'var(--color-text)' }}>
<span style={{ fontSize: '16px' }}>🔐</span>
<span>Board Permission</span>
</span>
<span style={{ fontSize: '14px' }}>🔐</span>
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>Board Permission</span>
<span style={{
fontSize: '11px',
padding: '2px 8px',
borderRadius: '4px',
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].icon} {PERMISSION_CONFIG[currentPermission].label}
{PERMISSION_CONFIG[currentPermission].label}
</span>
</div>
{/* Permission levels with request buttons */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '8px' }}>
{/* 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
@ -439,23 +464,35 @@ function CustomSharePanel() {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 8px',
padding: '8px 10px',
borderRadius: '6px',
background: isCurrent ? `${config.color}15` : 'transparent',
border: isCurrent ? `1px solid ${config.color}40` : '1px solid transparent',
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: '6px',
gap: '8px',
fontSize: '12px',
color: isCurrent ? config.color : 'var(--color-text-3)',
color: isCurrent ? config.color : 'var(--color-text)',
fontWeight: isCurrent ? 600 : 400,
}}>
<span>{config.icon}</span>
<span style={{ fontSize: '14px' }}>{config.icon}</span>
<span>{config.label}</span>
{isCurrent && <span style={{ fontSize: '10px', opacity: 0.7 }}>(current)</span>}
{isCurrent && (
<span style={{
fontSize: '9px',
padding: '2px 6px',
borderRadius: '10px',
background: config.color,
color: 'white',
fontWeight: 500,
}}>
Current
</span>
)}
</span>
{canRequest && (
@ -463,15 +500,24 @@ function CustomSharePanel() {
onClick={() => handleRequestPermission(level)}
disabled={permissionRequestStatus === 'sending'}
style={{
padding: '3px 8px',
padding: '4px 10px',
fontSize: '10px',
fontWeight: 500,
fontWeight: 600,
borderRadius: '4px',
border: 'none',
background: config.color,
color: 'white',
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'}
@ -485,10 +531,10 @@ function CustomSharePanel() {
{/* Request status message */}
{requestMessage && (
<p style={{
margin: '8px 0 0',
margin: '10px 0 0',
fontSize: '11px',
padding: '6px 8px',
borderRadius: '4px',
padding: '8px 12px',
borderRadius: '6px',
background: permissionRequestStatus === 'sent' ? '#d1fae5' :
permissionRequestStatus === 'error' ? '#fee2e2' : 'var(--color-muted-2)',
color: permissionRequestStatus === 'sent' ? '#065f46' :
@ -501,62 +547,83 @@ function CustomSharePanel() {
{!session.authed && (
<p style={{
margin: '8px 0 0',
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: '4px 0' }} />
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '0' }} />
{/* Dark mode toggle */}
<button
onClick={() => {
handleToggleDarkMode()
}}
style={{
width: '100%',
{/* Appearance Toggle */}
<div style={{ padding: '12px 16px' }}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
padding: '10px 16px',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-text)',
fontSize: '13px',
textAlign: 'left',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-muted-2)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'none'
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '16px' }}>{isDarkMode ? '🌙' : '☀️'}</span>
}}>
<span 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={{
fontSize: '11px',
padding: '2px 8px',
borderRadius: '4px',
background: 'var(--color-muted-2)',
color: 'var(--color-text-3)',
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',
}}>
{isDarkMode ? 'Dark' : 'Light'}
</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: '4px 0' }} />
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '0' }} />
{/* AI Models expandable section */}
{/* AI Models Accordion */}
<div>
<button
onClick={() => setShowAISection(!showAISection)}
@ -565,42 +632,81 @@ function CustomSharePanel() {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
padding: '10px 16px',
background: 'none',
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) => {
e.currentTarget.style.background = 'var(--color-muted-2)'
if (!showAISection) e.currentTarget.style.background = 'var(--color-muted-2)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'none'
if (!showAISection) e.currentTarget.style.background = 'none'
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '16px' }}>🤖</span>
<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' }}
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: '8px 16px', backgroundColor: 'var(--color-muted-2)' }}>
<p style={{ fontSize: '10px', color: 'var(--color-text-3)', marginBottom: '8px' }}>
Local models are free. Cloud models require API keys.
<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}
@ -608,49 +714,104 @@ function CustomSharePanel() {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 0',
borderBottom: '1px solid var(--color-panel-contrast)',
padding: '8px 10px',
background: 'var(--color-panel)',
borderRadius: '6px',
border: '1px solid var(--color-panel-contrast)',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: 'var(--color-text)' }}>
<span>{tool.icon}</span>
<span>{tool.name}</span>
<span 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: '2px 6px',
borderRadius: '10px',
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: 500,
fontWeight: 600,
}}
>
{tool.model}
</span>
</div>
))}
</div>
<button
onClick={handleManageApiKeys}
style={{
width: '100%',
marginTop: '8px',
padding: '6px 10px',
marginTop: '12px',
padding: '8px 12px',
fontSize: '11px',
fontWeight: 500,
fontFamily: 'inherit',
backgroundColor: 'var(--color-primary, #3b82f6)',
color: 'white',
border: 'none',
borderRadius: '4px',
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
@ -723,13 +884,16 @@ function CustomSharePanel() {
maxHeight: '50vh',
overflowY: 'auto',
overflowX: 'hidden',
background: 'var(--color-panel)',
background: 'var(--color-panel, #ffffff)',
backgroundColor: 'var(--color-panel, #ffffff)',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '8px',
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
boxShadow: '0 4px 20px rgba(0,0,0,0.25)',
zIndex: 99999,
padding: '10px 0',
pointerEvents: 'auto',
backdropFilter: 'none',
opacity: 1,
}}
onWheel={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
@ -808,6 +972,22 @@ function CustomSharePanel() {
document.body
)}
{/* 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>
)
}
@ -849,7 +1029,7 @@ export const components: TLComponents = {
tools["Holon"],
tools["FathomMeetings"],
tools["ImageGen"],
tools["VideoGen"],
// tools["VideoGen"], // Temporarily hidden
tools["Multmux"],
// MycelialIntelligence moved to permanent floating bar
].filter(tool => tool && tool.kbd)

View File

@ -222,6 +222,14 @@ export const overrides: TLUiOverrides = {
readonlyOk: true,
onSelect: () => editor.setCurrentTool("VideoGen"),
},
Drawfast: {
id: "Drawfast",
icon: "tool-pencil",
label: "Drawfast (AI Sketch)",
kbd: "ctrl+shift+d",
readonlyOk: true,
onSelect: () => editor.setCurrentTool("Drawfast"),
},
Multmux: {
id: "Multmux",
icon: "terminal",

View File

@ -14,6 +14,7 @@ const TOOL_DIMENSIONS: Record<string, { w: number; h: number }> = {
Prompt: { w: 300, h: 500 },
ImageGen: { w: 400, h: 450 },
VideoGen: { w: 400, h: 350 },
Drawfast: { w: 512, h: 512 },
ChatBox: { w: 400, h: 500 },
Markdown: { w: 400, h: 400 },
ObsNote: { w: 280, h: 200 },

View File

@ -64,12 +64,13 @@ export async function getEffectivePermission(
};
}
// Check for explicit permission
// Check for explicit user-specific permission
const explicitPerm = await db.prepare(
'SELECT * FROM board_permissions WHERE board_id = ? AND user_id = ?'
).bind(boardId, userId).first<BoardPermission>();
if (explicitPerm) {
// User has a specific permission set - use it (could be view, edit, or admin)
return {
permission: explicitPerm.permission,
isOwner: false,
@ -77,14 +78,11 @@ export async function getEffectivePermission(
};
}
// Fall back to default permission, but authenticated users get at least 'edit'
// (unless board explicitly restricts to view-only)
const defaultPerm = board.default_permission as PermissionLevel;
// For most boards, authenticated users can edit
// Board owners can set default_permission to 'view' to restrict this
// No explicit permission for this user
// Authenticated users get 'edit' by default
// (Board's default_permission only affects anonymous users with access tokens)
return {
permission: defaultPerm === 'view' ? 'view' : 'edit',
permission: 'edit',
isOwner: false,
boardExists: true
};

View File

@ -269,6 +269,45 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
})
})
// Version History API - forward to Durable Object
.get("/room/:roomId/history", async (request, env) => {
const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.AUTOMERGE_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
headers: request.headers,
method: "GET",
})
})
.get("/room/:roomId/snapshot/:hash", async (request, env) => {
const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.AUTOMERGE_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
headers: request.headers,
method: "GET",
})
})
.post("/room/:roomId/diff", async (request, env) => {
const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.AUTOMERGE_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
method: "POST",
body: request.body,
headers: request.headers,
})
})
.post("/room/:roomId/revert", async (request, env) => {
const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.AUTOMERGE_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
method: "POST",
body: request.body,
headers: request.headers,
})
})
.post("/daily/rooms", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]