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:
parent
6f68fcd4ae
commit
2988b84689
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue