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>
|
<head>
|
||||||
<title>Jeff Emmett</title>
|
<title>Jeff Emmett</title>
|
||||||
<meta charset="UTF-8" />
|
<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 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=*">
|
<meta http-equiv="Permissions-Policy" content="midi=*, microphone=*, camera=*, autoplay=*">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"@chengsokdara/use-whisper": "^0.2.0",
|
"@chengsokdara/use-whisper": "^0.2.0",
|
||||||
"@daily-co/daily-js": "^0.60.0",
|
"@daily-co/daily-js": "^0.60.0",
|
||||||
"@daily-co/daily-react": "^0.20.0",
|
"@daily-co/daily-react": "^0.20.0",
|
||||||
|
"@fal-ai/client": "^1.7.2",
|
||||||
"@mdxeditor/editor": "^3.51.0",
|
"@mdxeditor/editor": "^3.51.0",
|
||||||
"@tldraw/assets": "^3.15.4",
|
"@tldraw/assets": "^3.15.4",
|
||||||
"@tldraw/tldraw": "^3.15.4",
|
"@tldraw/tldraw": "^3.15.4",
|
||||||
|
|
@ -1918,6 +1919,20 @@
|
||||||
"node": ">=18"
|
"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": {
|
"node_modules/@fastify/busboy": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
|
||||||
|
|
@ -3505,6 +3520,15 @@
|
||||||
"react-dom": ">= 18 || >= 19"
|
"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": {
|
"node_modules/@multiformats/dns": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@multiformats/dns/-/dns-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@multiformats/dns/-/dns-1.0.10.tgz",
|
||||||
|
|
@ -10098,6 +10122,15 @@
|
||||||
"node": ">=0.8.x"
|
"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": {
|
"node_modules/exit-hook": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
|
||||||
|
|
@ -15807,6 +15840,12 @@
|
||||||
"node": ">= 0.8.15"
|
"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": {
|
"node_modules/robust-predicates": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
"@chengsokdara/use-whisper": "^0.2.0",
|
"@chengsokdara/use-whisper": "^0.2.0",
|
||||||
"@daily-co/daily-js": "^0.60.0",
|
"@daily-co/daily-js": "^0.60.0",
|
||||||
"@daily-co/daily-react": "^0.20.0",
|
"@daily-co/daily-react": "^0.20.0",
|
||||||
|
"@fal-ai/client": "^1.7.2",
|
||||||
"@mdxeditor/editor": "^3.51.0",
|
"@mdxeditor/editor": "^3.51.0",
|
||||||
"@tldraw/assets": "^3.15.4",
|
"@tldraw/assets": "^3.15.4",
|
||||||
"@tldraw/tldraw": "^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 [nfcStatus, setNfcStatus] = useState<'idle' | 'writing' | 'success' | 'error' | 'unsupported'>('idle');
|
||||||
const [nfcMessage, setNfcMessage] = useState('');
|
const [nfcMessage, setNfcMessage] = useState('');
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const [inviteInput, setInviteInput] = useState('');
|
||||||
|
const [inviteStatus, setInviteStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dropdownMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(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
|
// Close dropdown when clicking outside or pressing ESC
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
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);
|
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 () => {
|
const handleNfcWrite = async () => {
|
||||||
if (!('NDEFReader' in window)) {
|
if (!('NDEFReader' in window)) {
|
||||||
setNfcStatus('unsupported');
|
setNfcStatus('unsupported');
|
||||||
|
|
@ -177,150 +202,243 @@ const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) =
|
||||||
{/* Dropdown - rendered via portal to break out of parent container */}
|
{/* Dropdown - rendered via portal to break out of parent container */}
|
||||||
{showDropdown && dropdownPosition && createPortal(
|
{showDropdown && dropdownPosition && createPortal(
|
||||||
<div
|
<div
|
||||||
|
ref={dropdownMenuRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: dropdownPosition.top,
|
top: dropdownPosition.top,
|
||||||
right: dropdownPosition.right,
|
right: dropdownPosition.right,
|
||||||
width: '320px',
|
width: '340px',
|
||||||
background: 'var(--color-panel)',
|
background: 'var(--color-panel)',
|
||||||
|
backgroundColor: 'var(--color-panel)',
|
||||||
|
backdropFilter: 'none',
|
||||||
|
opacity: 1,
|
||||||
border: '1px solid var(--color-panel-contrast)',
|
border: '1px solid var(--color-panel-contrast)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||||||
zIndex: 100000,
|
zIndex: 100000,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
pointerEvents: 'all',
|
pointerEvents: 'all',
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
}}
|
}}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
onWheel={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Compact Header */}
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '12px 16px',
|
padding: '12px 14px',
|
||||||
borderBottom: '1px solid var(--color-panel-contrast)',
|
borderBottom: '1px solid var(--color-panel-contrast)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
|
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
Invite to Board
|
<span style={{ fontSize: '14px' }}>👥</span> Share Board
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDropdown(false)}
|
onClick={() => setShowDropdown(false)}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: 'var(--color-muted-2)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
padding: '4px',
|
padding: '4px 8px',
|
||||||
color: 'var(--color-text-3)',
|
color: 'var(--color-text-3)',
|
||||||
fontSize: '18px',
|
fontSize: '11px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
|
borderRadius: '4px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
x
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
{/* Board name */}
|
{/* Invite by username/email */}
|
||||||
<div style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
padding: '6px 10px',
|
|
||||||
backgroundColor: 'var(--color-muted-2)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>Board: </span>
|
|
||||||
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>{boardSlug}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Permission selector */}
|
|
||||||
<div>
|
<div>
|
||||||
<span style={{ fontSize: '11px', color: 'var(--color-text-3)', fontWeight: 500, marginBottom: '6px', display: 'block' }}>Access Level</span>
|
<div style={{
|
||||||
<div style={{ display: 'flex', gap: '6px' }}>
|
display: 'flex',
|
||||||
{(['view', 'edit', 'admin'] as PermissionType[]).map((perm) => {
|
gap: '8px',
|
||||||
const isActive = permission === perm;
|
}}>
|
||||||
const { label, color } = PERMISSION_LABELS[perm];
|
<input
|
||||||
return (
|
type="text"
|
||||||
<button
|
placeholder="Username or email..."
|
||||||
key={perm}
|
value={inviteInput}
|
||||||
onClick={() => setPermission(perm)}
|
onChange={(e) => setInviteInput(e.target.value)}
|
||||||
style={{
|
onKeyDown={(e) => {
|
||||||
flex: 1,
|
e.stopPropagation();
|
||||||
padding: '8px 6px',
|
if (e.key === 'Enter') handleInvite();
|
||||||
border: isActive ? `2px solid ${color}` : '2px solid var(--color-panel-contrast)',
|
}}
|
||||||
background: isActive ? `${color}15` : 'var(--color-panel)',
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
borderRadius: '6px',
|
onFocus={(e) => e.stopPropagation()}
|
||||||
cursor: 'pointer',
|
style={{
|
||||||
fontSize: '12px',
|
flex: 1,
|
||||||
fontWeight: isActive ? 600 : 500,
|
padding: '8px 12px',
|
||||||
color: isActive ? color : 'var(--color-text)',
|
fontSize: '12px',
|
||||||
transition: 'all 0.15s ease',
|
fontFamily: 'inherit',
|
||||||
}}
|
border: '1px solid var(--color-panel-contrast)',
|
||||||
>
|
borderRadius: '6px',
|
||||||
{label}
|
background: 'var(--color-panel)',
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
{inviteStatus === 'error' && (
|
||||||
|
<p style={{ fontSize: '11px', color: '#ef4444', marginTop: '4px' }}>
|
||||||
|
Failed to send invite. Please try again.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* QR Code and URL */}
|
{/* Divider with "or share link" */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '12px',
|
alignItems: 'center',
|
||||||
padding: '12px',
|
gap: '10px',
|
||||||
backgroundColor: 'var(--color-muted-2)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
}}>
|
}}>
|
||||||
{/* QR Code */}
|
<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, description } = PERMISSION_LABELS[perm];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={perm}
|
||||||
|
onClick={() => setPermission(perm)}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
title={description}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 6px',
|
||||||
|
border: 'none',
|
||||||
|
background: isActive ? '#3b82f6' : 'var(--color-muted-2)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* QR Code and URL - larger and side by side */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '14px',
|
||||||
|
padding: '14px',
|
||||||
|
backgroundColor: 'var(--color-muted-2)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
}}>
|
||||||
|
{/* QR Code - larger */}
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '8px',
|
padding: '10px',
|
||||||
backgroundColor: 'white',
|
backgroundColor: 'white',
|
||||||
borderRadius: '6px',
|
borderRadius: '8px',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
}}>
|
}}>
|
||||||
<QRCodeSVG
|
<QRCodeSVG
|
||||||
value={getShareUrl()}
|
value={getShareUrl()}
|
||||||
size={80}
|
size={100}
|
||||||
level="M"
|
level="M"
|
||||||
includeMargin={false}
|
includeMargin={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* URL and Copy */}
|
{/* URL and Copy - stacked */}
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: '8px' }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: '10px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '8px',
|
padding: '10px 12px',
|
||||||
backgroundColor: 'var(--color-panel)',
|
backgroundColor: 'var(--color-panel)',
|
||||||
borderRadius: '4px',
|
borderRadius: '6px',
|
||||||
border: '1px solid var(--color-panel-contrast)',
|
border: '1px solid var(--color-panel-contrast)',
|
||||||
wordBreak: 'break-all',
|
wordBreak: 'break-all',
|
||||||
fontSize: '10px',
|
fontSize: '11px',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
color: 'var(--color-text)',
|
color: 'var(--color-text)',
|
||||||
maxHeight: '40px',
|
lineHeight: 1.4,
|
||||||
overflowY: 'auto',
|
|
||||||
}}>
|
}}>
|
||||||
{getShareUrl()}
|
{getShareUrl()}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleCopyUrl}
|
onClick={handleCopyUrl}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 12px',
|
padding: '8px 12px',
|
||||||
backgroundColor: copied ? '#10b981' : '#3b82f6',
|
backgroundColor: copied ? '#10b981' : '#3b82f6',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '6px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '12px',
|
fontSize: '11px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
|
fontFamily: 'inherit',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: '4px',
|
gap: '6px',
|
||||||
transition: 'background 0.15s',
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -329,28 +447,42 @@ const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) =
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
|
width: '100%',
|
||||||
background: 'none',
|
background: 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
color: 'var(--color-text-3)',
|
color: 'var(--color-text-3)',
|
||||||
padding: '4px 0',
|
padding: '6px 0',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '4px',
|
justifyContent: 'center',
|
||||||
|
gap: '6px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<span style={{
|
||||||
width="10"
|
display: 'flex',
|
||||||
height="10"
|
alignItems: 'center',
|
||||||
viewBox="0 0 16 16"
|
justifyContent: 'center',
|
||||||
fill="currentColor"
|
width: '16px',
|
||||||
style={{ transform: showAdvanced ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
|
height: '16px',
|
||||||
>
|
borderRadius: '4px',
|
||||||
<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"/>
|
background: 'var(--color-muted-2)',
|
||||||
</svg>
|
}}>
|
||||||
More options (NFC, Audio)
|
<svg
|
||||||
|
width="10"
|
||||||
|
height="10"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
style={{ transform: showAdvanced ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
|
||||||
|
>
|
||||||
|
<path d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
More options
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showAdvanced && (
|
{showAdvanced && (
|
||||||
|
|
@ -358,10 +490,12 @@ const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) =
|
||||||
{/* NFC Button */}
|
{/* NFC Button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleNfcWrite}
|
onClick={handleNfcWrite}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
disabled={nfcStatus === 'unsupported' || nfcStatus === 'writing'}
|
disabled={nfcStatus === 'unsupported' || nfcStatus === 'writing'}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: '10px',
|
padding: '10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
backgroundColor: nfcStatus === 'unsupported' ? 'var(--color-muted-2)' :
|
backgroundColor: nfcStatus === 'unsupported' ? 'var(--color-muted-2)' :
|
||||||
nfcStatus === 'success' ? '#d1fae5' :
|
nfcStatus === 'success' ? '#d1fae5' :
|
||||||
nfcStatus === 'error' ? '#fee2e2' :
|
nfcStatus === 'error' ? '#fee2e2' :
|
||||||
|
|
@ -390,9 +524,11 @@ const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) =
|
||||||
{/* Audio Button (coming soon) */}
|
{/* Audio Button (coming soon) */}
|
||||||
<button
|
<button
|
||||||
disabled
|
disabled
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: '10px',
|
padding: '10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
backgroundColor: 'var(--color-muted-2)',
|
backgroundColor: 'var(--color-muted-2)',
|
||||||
border: '1px solid var(--color-panel-contrast)',
|
border: '1px solid var(--color-panel-contrast)',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
|
|
|
||||||
|
|
@ -350,15 +350,18 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
right: dropdownPosition.right,
|
right: dropdownPosition.right,
|
||||||
minWidth: '260px',
|
minWidth: '260px',
|
||||||
maxHeight: 'calc(100vh - 100px)',
|
maxHeight: 'calc(100vh - 100px)',
|
||||||
background: 'var(--color-background)',
|
background: 'var(--color-background, #ffffff)',
|
||||||
|
backgroundColor: 'var(--color-background, #ffffff)',
|
||||||
border: '1px solid var(--color-grid)',
|
border: '1px solid var(--color-grid)',
|
||||||
borderRadius: '8px',
|
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,
|
zIndex: 100000,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
overflowX: 'hidden',
|
overflowX: 'hidden',
|
||||||
pointerEvents: 'all',
|
pointerEvents: 'all',
|
||||||
fontFamily: 'var(--tl-font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif)',
|
fontFamily: 'var(--tl-font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif)',
|
||||||
|
backdropFilter: 'none',
|
||||||
|
opacity: 1,
|
||||||
}}
|
}}
|
||||||
onWheel={(e) => {
|
onWheel={(e) => {
|
||||||
// Stop wheel events from propagating to canvas when over menu
|
// Stop wheel events from propagating to canvas when over menu
|
||||||
|
|
@ -408,36 +411,44 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick actions */}
|
{/* Quick actions */}
|
||||||
<div style={{ padding: '4px', borderBottom: '1px solid var(--color-grid)' }}>
|
<div style={{ padding: '8px', borderBottom: '1px solid var(--color-grid)' }}>
|
||||||
<a
|
<a
|
||||||
href="/dashboard/"
|
href="/dashboard/"
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
padding: '8px 10px',
|
padding: '10px 16px',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
fontWeight: 500,
|
fontWeight: 600,
|
||||||
color: 'var(--color-text)',
|
color: 'white',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
transition: 'background 0.1s',
|
transition: 'all 0.15s',
|
||||||
borderRadius: '4px',
|
borderRadius: '6px',
|
||||||
pointerEvents: 'all',
|
pointerEvents: 'all',
|
||||||
|
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||||
|
boxShadow: '0 2px 4px rgba(99, 102, 241, 0.3)',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
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) => {
|
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"/>
|
<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>
|
</svg>
|
||||||
My Saved Boards
|
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' }}>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<polyline points="9 18 15 12 9 6"/>
|
<line x1="7" y1="17" x2="17" y2="7"/>
|
||||||
|
<polyline points="7 7 17 7 17 17"/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -455,8 +466,8 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
Integrations
|
Integrations
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Google Workspace */}
|
{/* Google Workspace - Coming Soon */}
|
||||||
<div style={{ padding: '6px 10px' }}>
|
<div style={{ padding: '6px 10px', opacity: 0.6 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '24px',
|
width: '24px',
|
||||||
|
|
@ -477,113 +488,28 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
Google Workspace
|
Google Workspace
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '11px', color: 'var(--color-text-2)' }}>
|
<div style={{ fontSize: '11px', color: 'var(--color-text-2)' }}>
|
||||||
{googleConnected ? `${totalGoogleItems} items imported` : 'Not connected'}
|
Coming soon
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{googleConnected && (
|
|
||||||
<span style={{
|
|
||||||
width: '6px',
|
|
||||||
height: '6px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: '#22c55e',
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '6px' }}>
|
<button
|
||||||
{googleConnected ? (
|
disabled
|
||||||
<>
|
style={{
|
||||||
<button
|
width: '100%',
|
||||||
onClick={() => {
|
padding: '6px 12px',
|
||||||
setShowGoogleBrowser(true);
|
fontSize: '12px',
|
||||||
setShowDropdown(false);
|
fontWeight: 600,
|
||||||
}}
|
borderRadius: '4px',
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
border: 'none',
|
||||||
style={{
|
background: '#9ca3af',
|
||||||
flex: 1,
|
color: 'white',
|
||||||
padding: '6px 12px',
|
cursor: 'not-allowed',
|
||||||
fontSize: '12px',
|
pointerEvents: 'none',
|
||||||
fontWeight: 600,
|
}}
|
||||||
borderRadius: '4px',
|
>
|
||||||
border: 'none',
|
Coming Soon
|
||||||
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
</button>
|
||||||
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)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Browse Data
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Obsidian Vault */}
|
{/* Obsidian Vault */}
|
||||||
|
|
@ -861,8 +787,8 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Miro Board Import */}
|
{/* Miro Board Import - Coming Soon */}
|
||||||
<div style={{ padding: '6px 10px', borderTop: '1px solid var(--color-grid)' }}>
|
<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={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '24px',
|
width: '24px',
|
||||||
|
|
@ -884,24 +810,12 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
Miro Boards
|
Miro Boards
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '11px', color: 'var(--color-text-2)' }}>
|
<div style={{ fontSize: '11px', color: 'var(--color-text-2)' }}>
|
||||||
{isMiroApiKeyConfigured(session.username) ? 'API connected' : 'Import via JSON'}
|
Coming soon
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isMiroApiKeyConfigured(session.username) && (
|
|
||||||
<span style={{
|
|
||||||
width: '6px',
|
|
||||||
height: '6px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: '#22c55e',
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
disabled
|
||||||
setShowMiroModal(true);
|
|
||||||
setShowDropdown(false);
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '6px 12px',
|
padding: '6px 12px',
|
||||||
|
|
@ -909,35 +823,13 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
background: isMiroApiKeyConfigured(session.username)
|
background: '#9ca3af',
|
||||||
? '#6b7280'
|
|
||||||
: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
|
||||||
color: 'white',
|
color: 'white',
|
||||||
cursor: 'pointer',
|
cursor: 'not-allowed',
|
||||||
pointerEvents: 'all',
|
pointerEvents: 'none',
|
||||||
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)';
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Import Miro Board
|
Coming Soon
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 { Session, SessionError, PermissionLevel } from '../lib/auth/types';
|
||||||
import { AuthService } from '../lib/auth/authService';
|
import { AuthService } from '../lib/auth/authService';
|
||||||
import { saveSession, clearStoredSession } from '../lib/auth/sessionPersistence';
|
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 [session, setSessionState] = useState<Session>(initialSession);
|
||||||
const [accessToken, setAccessTokenState] = useState<string | null>(null);
|
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
|
// Extract access token from URL on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
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);
|
const result = await AuthService.login(username);
|
||||||
|
|
||||||
if (result.success && result.session) {
|
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
|
// IMPORTANT: Clear permission cache when auth state changes
|
||||||
// This forces a fresh permission fetch with the new credentials
|
// This forces a fresh permission fetch with the new credentials
|
||||||
setSessionState({
|
setSessionState({
|
||||||
|
|
@ -112,7 +119,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
boardPermissions: {},
|
boardPermissions: {},
|
||||||
currentBoardPermission: undefined,
|
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
|
// Save session to localStorage if authenticated
|
||||||
if (result.session.authed && result.session.username) {
|
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);
|
const result = await AuthService.register(username);
|
||||||
|
|
||||||
if (result.success && result.session) {
|
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
|
// IMPORTANT: Clear permission cache when auth state changes
|
||||||
// This forces a fresh permission fetch with the new credentials
|
// This forces a fresh permission fetch with the new credentials
|
||||||
setSessionState({
|
setSessionState({
|
||||||
|
|
@ -155,7 +165,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
boardPermissions: {},
|
boardPermissions: {},
|
||||||
currentBoardPermission: undefined,
|
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
|
// Save session to localStorage if authenticated
|
||||||
if (result.session.authed && result.session.username) {
|
if (result.session.authed && result.session.username) {
|
||||||
|
|
@ -185,6 +195,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
* Clear the current session
|
* Clear the current session
|
||||||
*/
|
*/
|
||||||
const clearSession = useCallback((): void => {
|
const clearSession = useCallback((): void => {
|
||||||
|
// IMPORTANT: Mark auth as just changed - prevents stale callbacks from using cache
|
||||||
|
authChangedAtRef.current = Date.now();
|
||||||
|
|
||||||
clearStoredSession();
|
clearStoredSession();
|
||||||
setSessionState({
|
setSessionState({
|
||||||
username: '',
|
username: '',
|
||||||
|
|
@ -197,6 +210,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
boardPermissions: {},
|
boardPermissions: {},
|
||||||
currentBoardPermission: undefined,
|
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)
|
* Includes access token if available (from share link)
|
||||||
*/
|
*/
|
||||||
const fetchBoardPermission = useCallback(async (boardId: string): Promise<PermissionLevel> => {
|
const fetchBoardPermission = useCallback(async (boardId: string): Promise<PermissionLevel> => {
|
||||||
// Check cache first (but only if no access token - token changes permissions)
|
// IMPORTANT: Check if auth state changed recently (within last 5 seconds)
|
||||||
if (!accessToken && session.boardPermissions?.[boardId]) {
|
// 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]);
|
console.log('🔐 Using cached permission for board:', boardId, session.boardPermissions[boardId]);
|
||||||
return 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 {
|
html.dark button:not([class*="primary"]):not([style*="background"]):hover {
|
||||||
background-color: var(--hover-bg);
|
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)'],
|
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 ===
|
// === Content & Notes Tools ===
|
||||||
|
|
||||||
ChatBox: {
|
ChatBox: {
|
||||||
|
|
@ -472,6 +498,11 @@ export function suggestToolsForIntent(intent: string): ToolSchema[] {
|
||||||
suggestions.push(TOOL_SCHEMAS.VideoGen)
|
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
|
// 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/)) {
|
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)
|
suggestions.push(TOOL_SCHEMAS.ChatBox)
|
||||||
|
|
@ -530,7 +561,7 @@ export function suggestToolsForIntent(intent: string): ToolSchema[] {
|
||||||
|
|
||||||
// Creative work
|
// Creative work
|
||||||
if (intentLower.match(/\b(creative|artistic|design|mood.?board|inspiration|concept|prototype|mockup)\b/)) {
|
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
|
// 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: 'Prompt', label: 'LLM Prompt', kbd: '⌃⇧L', key: 'L', icon: '🤖', category: 'tool' },
|
||||||
{ id: 'ObsidianNote', label: 'Obsidian Note', kbd: '⌃⇧O', key: 'O', 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: '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: 'FathomMeetings', label: 'Fathom Meetings', kbd: '⌃⇧F', key: 'F', icon: '📅', category: 'tool' },
|
||||||
{ id: 'ImageGen', label: 'Image Gen', kbd: '⌃⇧I', key: 'I', 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: 'VideoGen', label: 'Video Gen', kbd: '⌃⇧G', key: 'G', icon: '🎬', category: 'tool' }, // Temporarily hidden
|
||||||
{ id: 'Multmux', label: 'Terminal', kbd: '⌃⇧K', key: 'K', icon: '💻', category: 'tool' },
|
// { id: 'Multmux', label: 'Terminal', kbd: '⌃⇧K', key: 'K', icon: '💻', category: 'tool' }, // Temporarily hidden
|
||||||
]
|
]
|
||||||
|
|
||||||
// Custom actions with shortcuts (matching overrides.tsx)
|
// Custom actions with shortcuts (matching overrides.tsx)
|
||||||
|
|
|
||||||
|
|
@ -232,14 +232,25 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
||||||
<TldrawUiMenuItem {...tools.Prompt} />
|
<TldrawUiMenuItem {...tools.Prompt} />
|
||||||
<TldrawUiMenuItem {...tools.ChatBox} />
|
<TldrawUiMenuItem {...tools.ChatBox} />
|
||||||
<TldrawUiMenuItem {...tools.ImageGen} />
|
<TldrawUiMenuItem {...tools.ImageGen} />
|
||||||
|
{/* VideoGen - temporarily hidden until in better working state
|
||||||
<TldrawUiMenuItem {...tools.VideoGen} />
|
<TldrawUiMenuItem {...tools.VideoGen} />
|
||||||
|
*/}
|
||||||
|
{/* Drawfast - temporarily hidden until in better working state
|
||||||
|
<TldrawUiMenuItem {...tools.Drawfast} />
|
||||||
|
*/}
|
||||||
<TldrawUiMenuItem {...tools.Markdown} />
|
<TldrawUiMenuItem {...tools.Markdown} />
|
||||||
<TldrawUiMenuItem {...tools.ObsidianNote} />
|
<TldrawUiMenuItem {...tools.ObsidianNote} />
|
||||||
<TldrawUiMenuItem {...tools.Transcription} />
|
<TldrawUiMenuItem {...tools.Transcription} />
|
||||||
<TldrawUiMenuItem {...tools.Embed} />
|
<TldrawUiMenuItem {...tools.Embed} />
|
||||||
|
{/* Holon - temporarily hidden until in better working state
|
||||||
<TldrawUiMenuItem {...tools.Holon} />
|
<TldrawUiMenuItem {...tools.Holon} />
|
||||||
|
*/}
|
||||||
|
{/* Terminal (Multmux) - temporarily hidden until in better working state
|
||||||
<TldrawUiMenuItem {...tools.Multmux} />
|
<TldrawUiMenuItem {...tools.Multmux} />
|
||||||
|
*/}
|
||||||
|
{/* Map - temporarily hidden until in better working state
|
||||||
<TldrawUiMenuItem {...tools.Map} />
|
<TldrawUiMenuItem {...tools.Map} />
|
||||||
|
*/}
|
||||||
<TldrawUiMenuItem {...tools.SlideShape} />
|
<TldrawUiMenuItem {...tools.SlideShape} />
|
||||||
<TldrawUiMenuItem {...tools.VideoChat} />
|
<TldrawUiMenuItem {...tools.VideoChat} />
|
||||||
<TldrawUiMenuItem {...tools.FathomMeetings} />
|
<TldrawUiMenuItem {...tools.FathomMeetings} />
|
||||||
|
|
|
||||||
|
|
@ -726,6 +726,7 @@ export function CustomToolbar() {
|
||||||
isSelected={tools["Transcription"].id === editor.getCurrentToolId()}
|
isSelected={tools["Transcription"].id === editor.getCurrentToolId()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Holon - temporarily hidden until in better working state
|
||||||
{tools["Holon"] && (
|
{tools["Holon"] && (
|
||||||
<TldrawUiMenuItem
|
<TldrawUiMenuItem
|
||||||
{...tools["Holon"]}
|
{...tools["Holon"]}
|
||||||
|
|
@ -734,6 +735,7 @@ export function CustomToolbar() {
|
||||||
isSelected={tools["Holon"].id === editor.getCurrentToolId()}
|
isSelected={tools["Holon"].id === editor.getCurrentToolId()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
*/}
|
||||||
{tools["FathomMeetings"] && (
|
{tools["FathomMeetings"] && (
|
||||||
<TldrawUiMenuItem
|
<TldrawUiMenuItem
|
||||||
{...tools["FathomMeetings"]}
|
{...tools["FathomMeetings"]}
|
||||||
|
|
@ -750,6 +752,7 @@ export function CustomToolbar() {
|
||||||
isSelected={tools["ImageGen"].id === editor.getCurrentToolId()}
|
isSelected={tools["ImageGen"].id === editor.getCurrentToolId()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* VideoGen - temporarily hidden until in better working state
|
||||||
{tools["VideoGen"] && (
|
{tools["VideoGen"] && (
|
||||||
<TldrawUiMenuItem
|
<TldrawUiMenuItem
|
||||||
{...tools["VideoGen"]}
|
{...tools["VideoGen"]}
|
||||||
|
|
@ -758,6 +761,8 @@ export function CustomToolbar() {
|
||||||
isSelected={tools["VideoGen"].id === editor.getCurrentToolId()}
|
isSelected={tools["VideoGen"].id === editor.getCurrentToolId()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
*/}
|
||||||
|
{/* Terminal (Multmux) - temporarily hidden until in better working state
|
||||||
{tools["Multmux"] && (
|
{tools["Multmux"] && (
|
||||||
<TldrawUiMenuItem
|
<TldrawUiMenuItem
|
||||||
{...tools["Multmux"]}
|
{...tools["Multmux"]}
|
||||||
|
|
@ -766,6 +771,8 @@ export function CustomToolbar() {
|
||||||
isSelected={tools["Multmux"].id === editor.getCurrentToolId()}
|
isSelected={tools["Multmux"].id === editor.getCurrentToolId()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
*/}
|
||||||
|
{/* Map - temporarily hidden until in better working state
|
||||||
{tools["Map"] && (
|
{tools["Map"] && (
|
||||||
<TldrawUiMenuItem
|
<TldrawUiMenuItem
|
||||||
{...tools["Map"]}
|
{...tools["Map"]}
|
||||||
|
|
@ -774,6 +781,7 @@ export function CustomToolbar() {
|
||||||
isSelected={tools["Map"].id === editor.getCurrentToolId()}
|
isSelected={tools["Map"].id === editor.getCurrentToolId()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
*/}
|
||||||
{/* Refresh All ObsNotes Button */}
|
{/* Refresh All ObsNotes Button */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const allShapes = editor.getCurrentPageShapes()
|
const allShapes = editor.getCurrentPageShapes()
|
||||||
|
|
|
||||||
|
|
@ -473,19 +473,18 @@ type FollowUpContext =
|
||||||
| { type: 'selection'; count: number; shapeTypes: Record<string, number> }
|
| { type: 'selection'; count: number; shapeTypes: Record<string, number> }
|
||||||
|
|
||||||
// Follow-up suggestions after transform commands
|
// 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[]> = {
|
const TRANSFORM_FOLLOWUPS: Record<string, FollowUpSuggestion[]> = {
|
||||||
// After arranging in a row
|
// After arranging in a row
|
||||||
'arrange-row': [
|
'arrange-row': [
|
||||||
{ label: 'make same size', prompt: 'make these the same size', icon: '📐', category: 'refine' },
|
{ 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: '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' },
|
{ label: 'group these', prompt: 'create a frame around these shapes', icon: '📦', category: 'organize' },
|
||||||
],
|
],
|
||||||
// After arranging in a column
|
// After arranging in a column
|
||||||
'arrange-column': [
|
'arrange-column': [
|
||||||
{ label: 'make same width', prompt: 'make these the same width', icon: '↔️', category: 'refine' },
|
{ 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: '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' },
|
{ label: 'add header', prompt: 'create a title above this column', icon: '📝', category: 'expand' },
|
||||||
],
|
],
|
||||||
// After arranging in a grid
|
// After arranging in a grid
|
||||||
|
|
@ -498,7 +497,6 @@ const TRANSFORM_FOLLOWUPS: Record<string, FollowUpSuggestion[]> = {
|
||||||
// After arranging in a circle
|
// After arranging in a circle
|
||||||
'arrange-circle': [
|
'arrange-circle': [
|
||||||
{ label: 'add center node', prompt: 'add a central connecting node', icon: '⭕', category: 'expand' },
|
{ 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' },
|
{ label: 'label the cycle', prompt: 'add a title for this cycle diagram', icon: '📝', category: 'expand' },
|
||||||
],
|
],
|
||||||
// After aligning
|
// After aligning
|
||||||
|
|
@ -522,7 +520,6 @@ const TRANSFORM_FOLLOWUPS: Record<string, FollowUpSuggestion[]> = {
|
||||||
'distribute-horizontal': [
|
'distribute-horizontal': [
|
||||||
{ label: 'align tops', prompt: 'align these to the top', icon: '⬆️', category: 'refine' },
|
{ 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: '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': [
|
'distribute-vertical': [
|
||||||
{ label: 'align left', prompt: 'align these to the left', icon: '⬅️', category: 'refine' },
|
{ 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
|
// After semantic clustering
|
||||||
'cluster-semantic': [
|
'cluster-semantic': [
|
||||||
{ label: 'label clusters', prompt: 'add a label to each cluster', icon: '🏷️', category: 'expand' },
|
{ 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: '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
|
// Generic follow-ups based on canvas state
|
||||||
|
// NOTE: Connection/arrow drawing is not yet implemented, so we use different suggestions
|
||||||
const CANVAS_STATE_FOLLOWUPS = {
|
const CANVAS_STATE_FOLLOWUPS = {
|
||||||
manyShapes: [
|
manyShapes: [
|
||||||
{ label: 'organize all', prompt: 'help me organize everything on this canvas', icon: '🗂️', category: 'organize' as const },
|
{ 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: [
|
hasText: [
|
||||||
{ label: 'summarize all', prompt: 'create a summary of all text content', icon: '📝', category: 'organize' as const },
|
{ 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: [
|
hasImages: [
|
||||||
{ label: 'describe images', prompt: 'what themes are in my images?', icon: '🖼️', category: 'expand' as const },
|
{ 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])
|
}, [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 [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1024)
|
||||||
const isMobile = windowWidth < 640
|
const isMobile = windowWidth < 640
|
||||||
const isNarrow = windowWidth < 768
|
const isNarrow = windowWidth < 768
|
||||||
|
|
@ -1437,13 +1435,23 @@ export function MycelialIntelligenceBar() {
|
||||||
return () => window.removeEventListener('resize', handleResize)
|
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)
|
// Height: taller when showing suggestion chips (single tool or 2+ selected)
|
||||||
// Base height matches the top-right menu (~40px) for visual alignment
|
// Base height matches the top-right menu (~40px) for visual alignment
|
||||||
const showSuggestions = selectedToolInfo || (selectionInfo && selectionInfo.count > 1)
|
const showSuggestions = selectedToolInfo || (selectionInfo && selectionInfo.count > 1)
|
||||||
const collapsedHeight = showSuggestions ? 68 : 40
|
const collapsedHeight = showSuggestions ? 68 : 40
|
||||||
const maxExpandedHeight = isMobile ? 300 : 400
|
const maxExpandedHeight = isMobile ? 300 : 400
|
||||||
// Responsive width: full width on mobile, percentage on narrow, fixed on desktop
|
// Responsive width: dynamically sized to fit between left and right menus
|
||||||
const barWidth = isMobile ? 'calc(100% - 20px)' : isNarrow ? 'calc(100% - 120px)' : 520
|
const barWidth = isMobile ? 'calc(100% - 20px)' : maxBarWidth
|
||||||
|
|
||||||
// Calculate dynamic height when expanded based on content
|
// Calculate dynamic height when expanded based on content
|
||||||
// Header: ~45px, Input area: ~56px, padding: ~24px = ~125px fixed
|
// Header: ~45px, Input area: ~56px, padding: ~24px = ~125px fixed
|
||||||
|
|
@ -1466,7 +1474,7 @@ export function MycelialIntelligenceBar() {
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
width: barWidth,
|
width: barWidth,
|
||||||
maxWidth: isMobile ? 'none' : '520px',
|
maxWidth: isMobile ? 'none' : `${maxBarWidth}px`,
|
||||||
height: isExpanded ? 'auto' : collapsedHeight,
|
height: isExpanded ? 'auto' : collapsedHeight,
|
||||||
minHeight: isExpanded ? minExpandedHeight : collapsedHeight,
|
minHeight: isExpanded ? minExpandedHeight : collapsedHeight,
|
||||||
maxHeight: isExpanded ? maxExpandedHeight : collapsedHeight,
|
maxHeight: isExpanded ? maxExpandedHeight : collapsedHeight,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import CryptIDDropdown from "../components/auth/CryptIDDropdown"
|
||||||
import StarBoardButton from "../components/StarBoardButton"
|
import StarBoardButton from "../components/StarBoardButton"
|
||||||
import ShareBoardButton from "../components/ShareBoardButton"
|
import ShareBoardButton from "../components/ShareBoardButton"
|
||||||
import { SettingsDialog } from "./SettingsDialog"
|
import { SettingsDialog } from "./SettingsDialog"
|
||||||
|
// import { VersionHistoryPanel } from "../components/history" // TODO: Re-enable when version reversion is ready
|
||||||
import { useAuth } from "../context/AuthContext"
|
import { useAuth } from "../context/AuthContext"
|
||||||
import { PermissionLevel } from "../lib/auth/types"
|
import { PermissionLevel } from "../lib/auth/types"
|
||||||
import { WORKER_URL } from "../constants/workerUrl"
|
import { WORKER_URL } from "../constants/workerUrl"
|
||||||
|
|
@ -54,6 +55,7 @@ function CustomSharePanel() {
|
||||||
|
|
||||||
const [showShortcuts, setShowShortcuts] = React.useState(false)
|
const [showShortcuts, setShowShortcuts] = React.useState(false)
|
||||||
const [showSettingsDropdown, setShowSettingsDropdown] = 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 [showAISection, setShowAISection] = React.useState(false)
|
||||||
const [hasApiKey, setHasApiKey] = React.useState(false)
|
const [hasApiKey, setHasApiKey] = React.useState(false)
|
||||||
const [permissionRequestStatus, setPermissionRequestStatus] = React.useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
|
const [permissionRequestStatus, setPermissionRequestStatus] = React.useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
|
||||||
|
|
@ -228,8 +230,8 @@ function CustomSharePanel() {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Custom tools
|
// Custom tools (VideoGen and Map temporarily hidden)
|
||||||
const customToolIds = ['VideoChat', 'ChatBox', 'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'Prompt', 'ObsidianNote', 'Transcription', 'Holon', 'FathomMeetings', 'ImageGen', 'VideoGen', 'Multmux']
|
const customToolIds = ['VideoChat', 'ChatBox', 'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'Prompt', 'ObsidianNote', 'Transcription', 'Holon', 'FathomMeetings', 'ImageGen', 'Multmux']
|
||||||
customToolIds.forEach(toolId => {
|
customToolIds.forEach(toolId => {
|
||||||
const tool = tools[toolId]
|
const tool = tools[toolId]
|
||||||
if (tool?.kbd) {
|
if (tool?.kbd) {
|
||||||
|
|
@ -299,9 +301,12 @@ function CustomSharePanel() {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0',
|
gap: '0',
|
||||||
background: 'var(--color-muted-1)',
|
background: isDarkMode ? '#2d2d2d' : '#f3f4f6',
|
||||||
|
backgroundColor: isDarkMode ? '#2d2d2d' : '#f3f4f6',
|
||||||
|
backdropFilter: 'none',
|
||||||
|
opacity: 1,
|
||||||
borderRadius: '20px',
|
borderRadius: '20px',
|
||||||
border: '1px solid var(--color-panel-contrast)',
|
border: `1px solid ${isDarkMode ? '#404040' : '#e5e7eb'}`,
|
||||||
padding: '4px 6px',
|
padding: '4px 6px',
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
}}>
|
}}>
|
||||||
|
|
@ -383,47 +388,67 @@ function CustomSharePanel() {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: settingsDropdownPos.top,
|
top: settingsDropdownPos.top,
|
||||||
right: settingsDropdownPos.right,
|
right: settingsDropdownPos.right,
|
||||||
minWidth: '200px',
|
minWidth: '220px',
|
||||||
maxHeight: '60vh',
|
maxHeight: '60vh',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
overflowX: 'hidden',
|
overflowX: 'hidden',
|
||||||
background: 'var(--color-panel)',
|
background: 'var(--color-panel)',
|
||||||
|
backgroundColor: 'var(--color-panel)',
|
||||||
border: '1px solid var(--color-panel-contrast)',
|
border: '1px solid var(--color-panel-contrast)',
|
||||||
borderRadius: '8px',
|
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,
|
zIndex: 99999,
|
||||||
padding: '8px 0',
|
padding: '8px 0',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
|
backdropFilter: 'none',
|
||||||
|
opacity: 1,
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
}}
|
}}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
onWheel={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Board Permission Display */}
|
{/* Board Permission Section */}
|
||||||
<div style={{ padding: '10px 16px' }}>
|
<div style={{ padding: '12px 16px 16px' }}>
|
||||||
|
{/* Section Header */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
gap: '8px',
|
||||||
marginBottom: '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: '14px' }}>🔐</span>
|
||||||
<span style={{ fontSize: '16px' }}>🔐</span>
|
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>Board Permission</span>
|
||||||
<span>Board Permission</span>
|
|
||||||
</span>
|
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '11px',
|
marginLeft: 'auto',
|
||||||
padding: '2px 8px',
|
fontSize: '10px',
|
||||||
borderRadius: '4px',
|
padding: '3px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
background: `${PERMISSION_CONFIG[currentPermission].color}20`,
|
background: `${PERMISSION_CONFIG[currentPermission].color}20`,
|
||||||
color: PERMISSION_CONFIG[currentPermission].color,
|
color: PERMISSION_CONFIG[currentPermission].color,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.3px',
|
||||||
}}>
|
}}>
|
||||||
{PERMISSION_CONFIG[currentPermission].icon} {PERMISSION_CONFIG[currentPermission].label}
|
{PERMISSION_CONFIG[currentPermission].label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Permission levels with request buttons */}
|
{/* Permission levels - indented to show hierarchy */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '8px' }}>
|
<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) => {
|
{(['view', 'edit', 'admin'] as PermissionLevel[]).map((level) => {
|
||||||
const config = PERMISSION_CONFIG[level]
|
const config = PERMISSION_CONFIG[level]
|
||||||
const isCurrent = currentPermission === level
|
const isCurrent = currentPermission === level
|
||||||
|
|
@ -439,23 +464,35 @@ function CustomSharePanel() {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
padding: '6px 8px',
|
padding: '8px 10px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
background: isCurrent ? `${config.color}15` : 'transparent',
|
background: isCurrent ? `${config.color}15` : 'var(--color-panel)',
|
||||||
border: isCurrent ? `1px solid ${config.color}40` : '1px solid transparent',
|
border: isCurrent ? `2px solid ${config.color}` : '1px solid var(--color-panel-contrast)',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{
|
<span style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '6px',
|
gap: '8px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: isCurrent ? config.color : 'var(--color-text-3)',
|
color: isCurrent ? config.color : 'var(--color-text)',
|
||||||
fontWeight: isCurrent ? 600 : 400,
|
fontWeight: isCurrent ? 600 : 400,
|
||||||
}}>
|
}}>
|
||||||
<span>{config.icon}</span>
|
<span style={{ fontSize: '14px' }}>{config.icon}</span>
|
||||||
<span>{config.label}</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>
|
</span>
|
||||||
|
|
||||||
{canRequest && (
|
{canRequest && (
|
||||||
|
|
@ -463,15 +500,24 @@ function CustomSharePanel() {
|
||||||
onClick={() => handleRequestPermission(level)}
|
onClick={() => handleRequestPermission(level)}
|
||||||
disabled={permissionRequestStatus === 'sending'}
|
disabled={permissionRequestStatus === 'sending'}
|
||||||
style={{
|
style={{
|
||||||
padding: '3px 8px',
|
padding: '4px 10px',
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
fontWeight: 500,
|
fontWeight: 600,
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
border: 'none',
|
border: `1px solid ${config.color}`,
|
||||||
background: config.color,
|
background: 'transparent',
|
||||||
color: 'white',
|
color: config.color,
|
||||||
cursor: permissionRequestStatus === 'sending' ? 'wait' : 'pointer',
|
cursor: permissionRequestStatus === 'sending' ? 'wait' : 'pointer',
|
||||||
opacity: permissionRequestStatus === 'sending' ? 0.6 : 1,
|
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'}
|
{permissionRequestStatus === 'sending' ? '...' : 'Request'}
|
||||||
|
|
@ -485,10 +531,10 @@ function CustomSharePanel() {
|
||||||
{/* Request status message */}
|
{/* Request status message */}
|
||||||
{requestMessage && (
|
{requestMessage && (
|
||||||
<p style={{
|
<p style={{
|
||||||
margin: '8px 0 0',
|
margin: '10px 0 0',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
padding: '6px 8px',
|
padding: '8px 12px',
|
||||||
borderRadius: '4px',
|
borderRadius: '6px',
|
||||||
background: permissionRequestStatus === 'sent' ? '#d1fae5' :
|
background: permissionRequestStatus === 'sent' ? '#d1fae5' :
|
||||||
permissionRequestStatus === 'error' ? '#fee2e2' : 'var(--color-muted-2)',
|
permissionRequestStatus === 'error' ? '#fee2e2' : 'var(--color-muted-2)',
|
||||||
color: permissionRequestStatus === 'sent' ? '#065f46' :
|
color: permissionRequestStatus === 'sent' ? '#065f46' :
|
||||||
|
|
@ -501,62 +547,83 @@ function CustomSharePanel() {
|
||||||
|
|
||||||
{!session.authed && (
|
{!session.authed && (
|
||||||
<p style={{
|
<p style={{
|
||||||
margin: '8px 0 0',
|
margin: '10px 0 0',
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
color: 'var(--color-text-3)',
|
color: 'var(--color-text-3)',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
fontStyle: 'italic',
|
||||||
}}>
|
}}>
|
||||||
Sign in to request higher permissions
|
Sign in to request higher permissions
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</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' }} />
|
||||||
|
|
||||||
{/* Dark mode toggle */}
|
{/* Appearance Toggle */}
|
||||||
<button
|
<div style={{ padding: '12px 16px' }}>
|
||||||
onClick={() => {
|
<div style={{
|
||||||
handleToggleDarkMode()
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
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>Appearance</span>
|
|
||||||
</span>
|
|
||||||
<span style={{
|
|
||||||
fontSize: '11px',
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
background: 'var(--color-muted-2)',
|
|
||||||
color: 'var(--color-text-3)',
|
|
||||||
}}>
|
}}>
|
||||||
{isDarkMode ? 'Dark' : 'Light'}
|
<span style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>
|
||||||
</span>
|
<span style={{ fontSize: '14px' }}>🎨</span>
|
||||||
</button>
|
<span>Appearance</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '4px 0' }} />
|
{/* Toggle Switch */}
|
||||||
|
<button
|
||||||
|
onClick={handleToggleDarkMode}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0',
|
||||||
|
padding: '3px',
|
||||||
|
background: isDarkMode ? '#374151' : '#e5e7eb',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '20px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Sun icon */}
|
||||||
|
<span style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: !isDarkMode ? '#ffffff' : 'transparent',
|
||||||
|
boxShadow: !isDarkMode ? '0 1px 3px rgba(0,0,0,0.2)' : 'none',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
☀️
|
||||||
|
</span>
|
||||||
|
{/* Moon icon */}
|
||||||
|
<span style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: isDarkMode ? '#1f2937' : 'transparent',
|
||||||
|
boxShadow: isDarkMode ? '0 1px 3px rgba(0,0,0,0.3)' : 'none',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
🌙
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* AI Models expandable section */}
|
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '0' }} />
|
||||||
|
|
||||||
|
{/* AI Models Accordion */}
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAISection(!showAISection)}
|
onClick={() => setShowAISection(!showAISection)}
|
||||||
|
|
@ -565,92 +632,186 @@ function CustomSharePanel() {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
gap: '12px',
|
padding: '12px 16px',
|
||||||
padding: '10px 16px',
|
background: showAISection ? 'var(--color-muted-2)' : 'none',
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: 'var(--color-text)',
|
color: 'var(--color-text)',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
fontFamily: 'inherit',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = 'var(--color-muted-2)'
|
if (!showAISection) e.currentTarget.style.background = 'var(--color-muted-2)'
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.background = 'none'
|
if (!showAISection) e.currentTarget.style.background = 'none'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<span style={{ fontSize: '16px' }}>🤖</span>
|
<span style={{ fontSize: '14px' }}>🤖</span>
|
||||||
<span>AI Models</span>
|
<span>AI Models</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '9px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
background: 'var(--color-muted-2)',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}>
|
||||||
|
{AI_TOOLS.length}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: showAISection ? 'var(--color-panel)' : 'var(--color-muted-2)',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
style={{
|
||||||
|
transform: showAISection ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||||
|
transition: 'transform 0.2s ease',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<path d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
|
||||||
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
style={{ transform: showAISection ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
|
|
||||||
>
|
|
||||||
<path d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showAISection && (
|
{showAISection && (
|
||||||
<div style={{ padding: '8px 16px', backgroundColor: 'var(--color-muted-2)' }}>
|
<div style={{
|
||||||
<p style={{ fontSize: '10px', color: 'var(--color-text-3)', marginBottom: '8px' }}>
|
padding: '12px 16px',
|
||||||
Local models are free. Cloud models require API keys.
|
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>
|
</p>
|
||||||
{AI_TOOLS.map((tool) => (
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
<div
|
{AI_TOOLS.map((tool) => (
|
||||||
key={tool.id}
|
<div
|
||||||
style={{
|
key={tool.id}
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '6px 0',
|
|
||||||
borderBottom: '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>
|
|
||||||
<span
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: '9px',
|
display: 'flex',
|
||||||
padding: '2px 6px',
|
alignItems: 'center',
|
||||||
borderRadius: '10px',
|
justifyContent: 'space-between',
|
||||||
backgroundColor: tool.type === 'local' ? '#d1fae5' : tool.type === 'gpu' ? '#e0e7ff' : '#fef3c7',
|
padding: '8px 10px',
|
||||||
color: tool.type === 'local' ? '#065f46' : tool.type === 'gpu' ? '#3730a3' : '#92400e',
|
background: 'var(--color-panel)',
|
||||||
fontWeight: 500,
|
borderRadius: '6px',
|
||||||
|
border: '1px solid var(--color-panel-contrast)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tool.model}
|
<span style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', color: 'var(--color-text)' }}>
|
||||||
</span>
|
<span style={{ fontSize: '14px' }}>{tool.icon}</span>
|
||||||
</div>
|
<span style={{ fontWeight: 500 }}>{tool.name}</span>
|
||||||
))}
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '9px',
|
||||||
|
padding: '3px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
backgroundColor: tool.type === 'local' ? '#d1fae5' : tool.type === 'gpu' ? '#e0e7ff' : '#fef3c7',
|
||||||
|
color: tool.type === 'local' ? '#065f46' : tool.type === 'gpu' ? '#3730a3' : '#92400e',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tool.model}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleManageApiKeys}
|
onClick={handleManageApiKeys}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
marginTop: '8px',
|
marginTop: '12px',
|
||||||
padding: '6px 10px',
|
padding: '8px 12px',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
|
fontFamily: 'inherit',
|
||||||
backgroundColor: 'var(--color-primary, #3b82f6)',
|
backgroundColor: 'var(--color-primary, #3b82f6)',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '6px',
|
||||||
cursor: 'pointer',
|
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'}
|
{hasApiKey ? 'Manage API Keys' : 'Add API Keys'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
</>,
|
</>,
|
||||||
document.body
|
document.body
|
||||||
|
|
@ -723,13 +884,16 @@ function CustomSharePanel() {
|
||||||
maxHeight: '50vh',
|
maxHeight: '50vh',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
overflowX: 'hidden',
|
overflowX: 'hidden',
|
||||||
background: 'var(--color-panel)',
|
background: 'var(--color-panel, #ffffff)',
|
||||||
|
backgroundColor: 'var(--color-panel, #ffffff)',
|
||||||
border: '1px solid var(--color-panel-contrast)',
|
border: '1px solid var(--color-panel-contrast)',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
|
boxShadow: '0 4px 20px rgba(0,0,0,0.25)',
|
||||||
zIndex: 99999,
|
zIndex: 99999,
|
||||||
padding: '10px 0',
|
padding: '10px 0',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
|
backdropFilter: 'none',
|
||||||
|
opacity: 1,
|
||||||
}}
|
}}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
onWheel={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
|
@ -808,6 +972,22 @@ function CustomSharePanel() {
|
||||||
document.body
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -849,7 +1029,7 @@ export const components: TLComponents = {
|
||||||
tools["Holon"],
|
tools["Holon"],
|
||||||
tools["FathomMeetings"],
|
tools["FathomMeetings"],
|
||||||
tools["ImageGen"],
|
tools["ImageGen"],
|
||||||
tools["VideoGen"],
|
// tools["VideoGen"], // Temporarily hidden
|
||||||
tools["Multmux"],
|
tools["Multmux"],
|
||||||
// MycelialIntelligence moved to permanent floating bar
|
// MycelialIntelligence moved to permanent floating bar
|
||||||
].filter(tool => tool && tool.kbd)
|
].filter(tool => tool && tool.kbd)
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,14 @@ export const overrides: TLUiOverrides = {
|
||||||
readonlyOk: true,
|
readonlyOk: true,
|
||||||
onSelect: () => editor.setCurrentTool("VideoGen"),
|
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: {
|
Multmux: {
|
||||||
id: "Multmux",
|
id: "Multmux",
|
||||||
icon: "terminal",
|
icon: "terminal",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const TOOL_DIMENSIONS: Record<string, { w: number; h: number }> = {
|
||||||
Prompt: { w: 300, h: 500 },
|
Prompt: { w: 300, h: 500 },
|
||||||
ImageGen: { w: 400, h: 450 },
|
ImageGen: { w: 400, h: 450 },
|
||||||
VideoGen: { w: 400, h: 350 },
|
VideoGen: { w: 400, h: 350 },
|
||||||
|
Drawfast: { w: 512, h: 512 },
|
||||||
ChatBox: { w: 400, h: 500 },
|
ChatBox: { w: 400, h: 500 },
|
||||||
Markdown: { w: 400, h: 400 },
|
Markdown: { w: 400, h: 400 },
|
||||||
ObsNote: { w: 280, h: 200 },
|
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(
|
const explicitPerm = await db.prepare(
|
||||||
'SELECT * FROM board_permissions WHERE board_id = ? AND user_id = ?'
|
'SELECT * FROM board_permissions WHERE board_id = ? AND user_id = ?'
|
||||||
).bind(boardId, userId).first<BoardPermission>();
|
).bind(boardId, userId).first<BoardPermission>();
|
||||||
|
|
||||||
if (explicitPerm) {
|
if (explicitPerm) {
|
||||||
|
// User has a specific permission set - use it (could be view, edit, or admin)
|
||||||
return {
|
return {
|
||||||
permission: explicitPerm.permission,
|
permission: explicitPerm.permission,
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
|
|
@ -77,14 +78,11 @@ export async function getEffectivePermission(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to default permission, but authenticated users get at least 'edit'
|
// No explicit permission for this user
|
||||||
// (unless board explicitly restricts to view-only)
|
// Authenticated users get 'edit' by default
|
||||||
const defaultPerm = board.default_permission as PermissionLevel;
|
// (Board's default_permission only affects anonymous users with access tokens)
|
||||||
|
|
||||||
// For most boards, authenticated users can edit
|
|
||||||
// Board owners can set default_permission to 'view' to restrict this
|
|
||||||
return {
|
return {
|
||||||
permission: defaultPerm === 'view' ? 'view' : 'edit',
|
permission: 'edit',
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
boardExists: true
|
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) => {
|
.post("/daily/rooms", async (req) => {
|
||||||
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue