diff --git a/index.html b/index.html index c82e56d..95b4df5 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ Jeff Emmett + diff --git a/package-lock.json b/package-lock.json index 200c5d4..2e1f49f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@chengsokdara/use-whisper": "^0.2.0", "@daily-co/daily-js": "^0.60.0", "@daily-co/daily-react": "^0.20.0", + "@fal-ai/client": "^1.7.2", "@mdxeditor/editor": "^3.51.0", "@tldraw/assets": "^3.15.4", "@tldraw/tldraw": "^3.15.4", @@ -1918,6 +1919,20 @@ "node": ">=18" } }, + "node_modules/@fal-ai/client": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@fal-ai/client/-/client-1.7.2.tgz", + "integrity": "sha512-RZ1Qz2Kza4ExKPy2D+2UUWthNApe+oZe8D1Wcxqleyn4F344MOm8ibgqG2JSVmybEcJAD4q44078WYfb6Q9c6w==", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^3.0.0-beta2", + "eventsource-parser": "^1.1.2", + "robot3": "^0.4.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", @@ -3505,6 +3520,15 @@ "react-dom": ">= 18 || >= 19" } }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.2.tgz", + "integrity": "sha512-JEW4DEtBzfe8HvUYecLU9e6+XJnKDlUAIve8FvPzF3Kzs6Xo/KuZkZJsDH0wJXl/qEZbeeE7edxDNY3kMs39hQ==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, "node_modules/@multiformats/dns": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@multiformats/dns/-/dns-1.0.10.tgz", @@ -10098,6 +10122,15 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource-parser": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz", + "integrity": "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==", + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, "node_modules/exit-hook": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", @@ -15807,6 +15840,12 @@ "node": ">= 0.8.15" } }, + "node_modules/robot3": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/robot3/-/robot3-0.4.1.tgz", + "integrity": "sha512-hzjy826lrxzx8eRgv80idkf8ua1JAepRc9Efdtj03N3KNJuznQCPlyCJ7gnUmDFwZCLQjxy567mQVKmdv2BsXQ==", + "license": "BSD-2-Clause" + }, "node_modules/robust-predicates": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", diff --git a/package.json b/package.json index e0a8ee3..3a7aba0 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@chengsokdara/use-whisper": "^0.2.0", "@daily-co/daily-js": "^0.60.0", "@daily-co/daily-react": "^0.20.0", + "@fal-ai/client": "^1.7.2", "@mdxeditor/editor": "^3.51.0", "@tldraw/assets": "^3.15.4", "@tldraw/tldraw": "^3.15.4", diff --git a/src/components/ShareBoardButton.tsx b/src/components/ShareBoardButton.tsx index 77a550f..b0db3f0 100644 --- a/src/components/ShareBoardButton.tsx +++ b/src/components/ShareBoardButton.tsx @@ -23,7 +23,10 @@ const ShareBoardButton: React.FC = ({ className = '' }) = const [nfcStatus, setNfcStatus] = useState<'idle' | 'writing' | 'success' | 'error' | 'unsupported'>('idle'); const [nfcMessage, setNfcMessage] = useState(''); const [showAdvanced, setShowAdvanced] = useState(false); + const [inviteInput, setInviteInput] = useState(''); + const [inviteStatus, setInviteStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); const dropdownRef = useRef(null); + const dropdownMenuRef = useRef(null); const triggerRef = useRef(null); const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null); @@ -58,7 +61,11 @@ const ShareBoardButton: React.FC = ({ className = '' }) = // Close dropdown when clicking outside or pressing ESC useEffect(() => { const handleClickOutside = (e: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + const target = e.target as Node; + // Check if click is inside trigger OR the portal dropdown menu + const isInsideTrigger = dropdownRef.current && dropdownRef.current.contains(target); + const isInsideMenu = dropdownMenuRef.current && dropdownMenuRef.current.contains(target); + if (!isInsideTrigger && !isInsideMenu) { setShowDropdown(false); } }; @@ -89,6 +96,24 @@ const ShareBoardButton: React.FC = ({ className = '' }) = } }; + const handleInvite = async () => { + if (!inviteInput.trim()) return; + + setInviteStatus('sending'); + try { + // TODO: Implement actual invite API call + // For now, simulate sending invite + await new Promise(resolve => setTimeout(resolve, 1000)); + setInviteStatus('sent'); + setInviteInput(''); + setTimeout(() => setInviteStatus('idle'), 3000); + } catch (err) { + console.error('Failed to send invite:', err); + setInviteStatus('error'); + setTimeout(() => setInviteStatus('idle'), 3000); + } + }; + const handleNfcWrite = async () => { if (!('NDEFReader' in window)) { setNfcStatus('unsupported'); @@ -177,150 +202,243 @@ const ShareBoardButton: React.FC = ({ className = '' }) = {/* Dropdown - rendered via portal to break out of parent container */} {showDropdown && dropdownPosition && createPortal(
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} > - {/* Header */} + {/* Compact Header */}
- - Invite to Board + + πŸ‘₯ Share Board
-
- {/* Board name */} -
- Board: - {boardSlug} -
- - {/* Permission selector */} +
+ {/* Invite by username/email */}
- Access Level -
- {(['view', 'edit', 'admin'] as PermissionType[]).map((perm) => { - const isActive = permission === perm; - const { label, color } = PERMISSION_LABELS[perm]; - return ( - - ); - })} +
+ setInviteInput(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === 'Enter') handleInvite(); + }} + onPointerDown={(e) => e.stopPropagation()} + onFocus={(e) => e.stopPropagation()} + style={{ + flex: 1, + padding: '8px 12px', + fontSize: '12px', + fontFamily: 'inherit', + border: '1px solid var(--color-panel-contrast)', + borderRadius: '6px', + background: 'var(--color-panel)', + color: 'var(--color-text)', + outline: 'none', + }} + /> +
+ {inviteStatus === 'error' && ( +

+ Failed to send invite. Please try again. +

+ )}
- {/* QR Code and URL */} + {/* Divider with "or share link" */}
- {/* QR Code */} +
+ or share link +
+
+ + {/* Permission selector - pill style */} +
+ {(['view', 'edit', 'admin'] as PermissionType[]).map((perm) => { + const isActive = permission === perm; + const { label, description } = PERMISSION_LABELS[perm]; + return ( + + ); + })} +
+ + {/* QR Code and URL - larger and side by side */} +
+ {/* QR Code - larger */}
- {/* URL and Copy */} -
+ {/* URL and Copy - stacked */} +
{getShareUrl()}
@@ -329,28 +447,42 @@ const ShareBoardButton: React.FC = ({ className = '' }) =
{showAdvanced && ( @@ -358,10 +490,12 @@ const ShareBoardButton: React.FC = ({ className = '' }) = {/* NFC Button */}
{/* Quick actions */} -
+
e.stopPropagation()} style={{ display: 'flex', alignItems: 'center', + justifyContent: 'center', gap: '8px', - padding: '8px 10px', + padding: '10px 16px', fontSize: '13px', - fontWeight: 500, - color: 'var(--color-text)', + fontWeight: 600, + color: 'white', textDecoration: 'none', - transition: 'background 0.1s', - borderRadius: '4px', + transition: 'all 0.15s', + borderRadius: '6px', pointerEvents: 'all', + background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', + boxShadow: '0 2px 4px rgba(99, 102, 241, 0.3)', }} onMouseEnter={(e) => { - e.currentTarget.style.background = 'var(--color-muted-2)'; + e.currentTarget.style.background = 'linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)'; + e.currentTarget.style.boxShadow = '0 4px 8px rgba(99, 102, 241, 0.4)'; + e.currentTarget.style.transform = 'translateY(-1px)'; }} onMouseLeave={(e) => { - e.currentTarget.style.background = 'transparent'; + e.currentTarget.style.background = 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)'; + e.currentTarget.style.boxShadow = '0 2px 4px rgba(99, 102, 241, 0.3)'; + e.currentTarget.style.transform = 'translateY(0)'; }} > - + My Saved Boards - - + + +
@@ -455,8 +466,8 @@ const CryptIDDropdown: React.FC = ({ isDarkMode = false }) Integrations
- {/* Google Workspace */} -
+ {/* Google Workspace - Coming Soon */} +
= ({ isDarkMode = false }) Google Workspace
- {googleConnected ? `${totalGoogleItems} items imported` : 'Not connected'} + Coming soon
- {googleConnected && ( - - )}
-
- {googleConnected ? ( - <> - - - - ) : ( - - )} -
+
{/* Obsidian Vault */} @@ -861,8 +787,8 @@ const CryptIDDropdown: React.FC = ({ isDarkMode = false }) )}
- {/* Miro Board Import */} -
+ {/* Miro Board Import - Coming Soon */} +
= ({ isDarkMode = false }) Miro Boards
- {isMiroApiKeyConfigured(session.username) ? 'API connected' : 'Import via JSON'} + Coming soon
- {isMiroApiKeyConfigured(session.username) && ( - - )}
diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 3a5d443..8e3d4bf 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from 'react'; +import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode, useRef } from 'react'; import { Session, SessionError, PermissionLevel } from '../lib/auth/types'; import { AuthService } from '../lib/auth/authService'; import { saveSession, clearStoredSession } from '../lib/auth/sessionPersistence'; @@ -41,6 +41,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const [session, setSessionState] = useState(initialSession); const [accessToken, setAccessTokenState] = useState(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(0); + // Extract access token from URL on mount useEffect(() => { const urlParams = new URLSearchParams(window.location.search); @@ -105,6 +109,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const result = await AuthService.login(username); if (result.success && result.session) { + // IMPORTANT: Mark auth as just changed - prevents stale callbacks from using cache + authChangedAtRef.current = Date.now(); + // IMPORTANT: Clear permission cache when auth state changes // This forces a fresh permission fetch with the new credentials setSessionState({ @@ -112,7 +119,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => boardPermissions: {}, currentBoardPermission: undefined, }); - console.log('πŸ” Login successful - cleared permission cache'); + console.log('πŸ” Login successful - cleared permission cache, authChangedAt:', authChangedAtRef.current); // Save session to localStorage if authenticated if (result.session.authed && result.session.username) { @@ -148,6 +155,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const result = await AuthService.register(username); if (result.success && result.session) { + // IMPORTANT: Mark auth as just changed - prevents stale callbacks from using cache + authChangedAtRef.current = Date.now(); + // IMPORTANT: Clear permission cache when auth state changes // This forces a fresh permission fetch with the new credentials setSessionState({ @@ -155,7 +165,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => boardPermissions: {}, currentBoardPermission: undefined, }); - console.log('πŸ” Registration successful - cleared permission cache'); + console.log('πŸ” Registration successful - cleared permission cache, authChangedAt:', authChangedAtRef.current); // Save session to localStorage if authenticated if (result.session.authed && result.session.username) { @@ -185,6 +195,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => * Clear the current session */ const clearSession = useCallback((): void => { + // IMPORTANT: Mark auth as just changed - prevents stale callbacks from using cache + authChangedAtRef.current = Date.now(); + clearStoredSession(); setSessionState({ username: '', @@ -197,6 +210,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => boardPermissions: {}, currentBoardPermission: undefined, }); + console.log('πŸ” Session cleared - marked auth as changed, authChangedAt:', authChangedAtRef.current); }, []); /** @@ -230,8 +244,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => * Includes access token if available (from share link) */ const fetchBoardPermission = useCallback(async (boardId: string): Promise => { - // Check cache first (but only if no access token - token changes permissions) - if (!accessToken && session.boardPermissions?.[boardId]) { + // IMPORTANT: Check if auth state changed recently (within last 5 seconds) + // If so, bypass cache entirely to prevent stale callbacks from returning old cached values + const authChangedRecently = Date.now() - authChangedAtRef.current < 5000; + if (authChangedRecently) { + console.log('πŸ” Auth changed recently, bypassing permission cache'); + } + + // Check cache first (but only if no access token and auth didn't just change) + if (!accessToken && !authChangedRecently && session.boardPermissions?.[boardId]) { console.log('πŸ” Using cached permission for board:', boardId, session.boardPermissions[boardId]); return session.boardPermissions[boardId]; } diff --git a/src/css/style.css b/src/css/style.css index 125980c..1968f3a 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -1950,4 +1950,5 @@ html.dark button:not([class*="primary"]):not([style*="background"]) { html.dark button:not([class*="primary"]):not([style*="background"]):hover { background-color: var(--hover-bg); -} \ No newline at end of file +} + diff --git a/src/hooks/useLiveImage.tsx b/src/hooks/useLiveImage.tsx new file mode 100644 index 0000000..22e0544 --- /dev/null +++ b/src/hooks/useLiveImage.tsx @@ -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(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( + 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 ( + + {children} + + ) +} + +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({ + isGenerating: false, + lastGeneratedUrl: null, + error: null, + }) + + const requestVersionRef = useRef(0) + const lastRequestTimeRef = useRef(0) + const pendingRequestRef = useRef(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 => { + 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 { + 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 + }) +} diff --git a/src/lib/toolSchema.ts b/src/lib/toolSchema.ts index 967b644..c0af983 100644 --- a/src/lib/toolSchema.ts +++ b/src/lib/toolSchema.ts @@ -118,6 +118,32 @@ export const TOOL_SCHEMAS: Record = { externalServices: ['RunPod GPU (Wan2.1)'], }, + Drawfast: { + id: 'Drawfast', + displayName: 'Drawfast', + primaryColor: '#06b6d4', + icon: '✏️', + purpose: 'Real-time AI-assisted sketching and drawing', + description: 'An AI drawing frame where you sketch using tldraw tools and AI enhances your drawings in real-time. Uses Fal.ai LCM for fast sketch-to-image generation. Draw inside the frame, then click Generate or enable real-time mode.', + capabilities: [ + { name: 'Real-time AI Enhancement', description: 'AI interprets and enhances sketches as you draw' }, + { name: 'Sketch-to-Image', description: 'Transform rough sketches into polished visuals via Fal.ai' }, + { name: 'Native tldraw Integration', description: 'Draw using pencil, pen, and other tldraw tools' }, + { name: 'Strength Control', description: 'Adjust how much AI transforms your sketch (10-90%)' }, + { name: 'Overlay/Side-by-side', description: 'View AI result overlaid on sketch or side-by-side' }, + ], + useCases: [ + 'Rapid concept sketching with AI assistance', + 'Visual brainstorming and ideation', + 'Transforming rough ideas into visuals', + 'Creating quick mockups and wireframes', + 'Exploring creative directions with AI', + ], + tags: ['ai', 'sketch', 'drawing', 'creative', 'real-time', 'interactive'], + requiresExternalServices: true, + externalServices: ['Fal.ai (LCM model)'], + }, + // === Content & Notes Tools === ChatBox: { @@ -472,6 +498,11 @@ export function suggestToolsForIntent(intent: string): ToolSchema[] { suggestions.push(TOOL_SCHEMAS.VideoGen) } + // Drawfast / AI Sketching intents + if (intentLower.match(/\b(drawfast|sketch|doodle|freehand|whiteboard|draw.*ai|ai.*draw|quick.*draw|rapid.*sketch|scribble|hand.*draw)\b/)) { + suggestions.push(TOOL_SCHEMAS.Drawfast) + } + // Chat/Conversation intents if (intentLower.match(/\b(chat|conversation|discuss|dialogue|talk|multi-turn|back.?and.?forth|iterative|deep.?dive|explore.?topic|q.?&.?a)\b/)) { suggestions.push(TOOL_SCHEMAS.ChatBox) @@ -530,7 +561,7 @@ export function suggestToolsForIntent(intent: string): ToolSchema[] { // Creative work if (intentLower.match(/\b(creative|artistic|design|mood.?board|inspiration|concept|prototype|mockup)\b/)) { - suggestions.push(TOOL_SCHEMAS.ImageGen, TOOL_SCHEMAS.Prompt, TOOL_SCHEMAS.Markdown) + suggestions.push(TOOL_SCHEMAS.ImageGen, TOOL_SCHEMAS.Drawfast, TOOL_SCHEMAS.Prompt, TOOL_SCHEMAS.Markdown) } // Meeting / Collaboration diff --git a/src/shapes/DrawfastShapeUtil.tsx b/src/shapes/DrawfastShapeUtil.tsx new file mode 100644 index 0000000..503d2d7 --- /dev/null +++ b/src/shapes/DrawfastShapeUtil.tsx @@ -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 { + 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 ( + + ) + } + + 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 | 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({ + id: shape.id, + type: 'Drawfast', + props: { + generatedImageUrl: imageUrl, + isGenerating: false, + }, + }) + }, + onError: (error) => { + console.error('Drawfast generation error:', error) + editor.updateShape({ + 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({ + id: shape.id, + type: shape.type, + props: { + pinnedToView: !shape.props.pinnedToView, + }, + }) + } + + const handlePromptChange = (newPrompt: string) => { + setLocalPrompt(newPrompt) + } + + const handlePromptSubmit = () => { + editor.updateShape({ + id: shape.id, + type: 'Drawfast', + props: { + prompt: localPrompt, + }, + }) + } + + const handleToggleOverlay = () => { + editor.updateShape({ + id: shape.id, + type: 'Drawfast', + props: { + overlayMode: !shape.props.overlayMode, + }, + }) + } + + const handleToggleAutoGenerate = () => { + editor.updateShape({ + 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({ + 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({ + 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((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({ + 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({ + id: shape.id, + type: 'Drawfast', + props: { isGenerating: false }, + }) + } + } + + const handleStrengthChange = (newStrength: number) => { + editor.updateShape({ + id: shape.id, + type: 'Drawfast', + props: { + strength: newStrength, + }, + }) + } + + // Custom header content + const headerContent = ( +
+ ✏️ + Drawfast + {(shape.props.isGenerating || liveImageState.isGenerating) && ( + + Generating... + + )} +
+ ) + + // Show API key setup if not configured + const showApiKeySetup = !liveImageContext?.isConnected + + return ( + + { + editor.updateShape({ + id: shape.id, + type: 'Drawfast', + props: { tags: newTags }, + }) + }} + tagsEditable={true} + > +
+ {/* Drawing Area / Result Display */} +
+ {/* Generated Image (if available and overlay mode) */} + {shape.props.generatedImageUrl && shape.props.overlayMode && ( + AI Generated + )} + + {/* Instructions when empty */} + {!shape.props.generatedImageUrl && ( +
+
✏️
+
Draw inside this frame
+
+ Use the pencil, pen, or other tools to sketch +
+
+ )} + + {/* Loading indicator */} + {(shape.props.isGenerating || liveImageState.isGenerating) && ( +
+
+ Generating... +
+ )} +
+ + {/* Side-by-side result (when not overlay mode) */} + {shape.props.generatedImageUrl && !shape.props.overlayMode && ( +
+ AI Generated +
+ )} + + {/* Controls */} +
+ {/* API Key Setup */} + {showApiKeySetup && ( +
+
+ Fal.ai API key not configured +
+ { + e.stopPropagation() + if (e.key === 'Enter') { + const value = (e.target as HTMLInputElement).value + if (value && liveImageContext) { + liveImageContext.setApiKey(value) + } + } + }} + onPointerDown={(e) => e.stopPropagation()} + /> +
+ Get your key at fal.ai +
+
+ )} + + {/* Prompt Input */} +
+ 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 && ( + + )} +
+ + {/* Settings Row */} +
+ {/* Strength Slider */} +
+ Strength: + handleStrengthChange(parseFloat(e.target.value))} + onPointerDown={(e) => e.stopPropagation()} + style={{ width: '60px', accentColor: DrawfastShape.PRIMARY_COLOR }} + /> + {Math.round(shape.props.strength * 100)}% +
+ + {/* Auto-generate toggle */} + + + {/* Overlay toggle */} + +
+
+
+ + + + + ) + } +} diff --git a/src/tools/DrawfastTool.ts b/src/tools/DrawfastTool.ts new file mode 100644 index 0000000..efa7a8e --- /dev/null +++ b/src/tools/DrawfastTool.ts @@ -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') + } +} diff --git a/src/ui/CommandPalette.tsx b/src/ui/CommandPalette.tsx index c7b898d..6bf59f5 100644 --- a/src/ui/CommandPalette.tsx +++ b/src/ui/CommandPalette.tsx @@ -30,11 +30,11 @@ export function CommandPalette() { { id: 'Prompt', label: 'LLM Prompt', kbd: 'βŒƒβ‡§L', key: 'L', icon: 'πŸ€–', category: 'tool' }, { id: 'ObsidianNote', label: 'Obsidian Note', kbd: 'βŒƒβ‡§O', key: 'O', icon: 'πŸ““', category: 'tool' }, { id: 'Transcription', label: 'Transcription', kbd: 'βŒƒβ‡§T', key: 'T', icon: '🎀', category: 'tool' }, - { id: 'Holon', label: 'Holon', kbd: 'βŒƒβ‡§H', key: 'H', icon: 'β­•', category: 'tool' }, + // { id: 'Holon', label: 'Holon', kbd: 'βŒƒβ‡§H', key: 'H', icon: 'β­•', category: 'tool' }, // Temporarily hidden { id: 'FathomMeetings', label: 'Fathom Meetings', kbd: 'βŒƒβ‡§F', key: 'F', icon: 'πŸ“…', category: 'tool' }, { id: 'ImageGen', label: 'Image Gen', kbd: 'βŒƒβ‡§I', key: 'I', icon: 'πŸ–ΌοΈ', category: 'tool' }, - { id: 'VideoGen', label: 'Video Gen', kbd: 'βŒƒβ‡§G', key: 'G', icon: '🎬', category: 'tool' }, - { id: 'Multmux', label: 'Terminal', kbd: 'βŒƒβ‡§K', key: 'K', icon: 'πŸ’»', category: 'tool' }, + // { id: 'VideoGen', label: 'Video Gen', kbd: 'βŒƒβ‡§G', key: 'G', icon: '🎬', category: 'tool' }, // Temporarily hidden + // { id: 'Multmux', label: 'Terminal', kbd: 'βŒƒβ‡§K', key: 'K', icon: 'πŸ’»', category: 'tool' }, // Temporarily hidden ] // Custom actions with shortcuts (matching overrides.tsx) diff --git a/src/ui/CustomContextMenu.tsx b/src/ui/CustomContextMenu.tsx index f8b4a2d..38adc76 100644 --- a/src/ui/CustomContextMenu.tsx +++ b/src/ui/CustomContextMenu.tsx @@ -232,14 +232,25 @@ export function CustomContextMenu(props: TLUiContextMenuProps) { + {/* VideoGen - temporarily hidden until in better working state + */} + {/* Drawfast - temporarily hidden until in better working state + + */} + {/* Holon - temporarily hidden until in better working state + */} + {/* Terminal (Multmux) - temporarily hidden until in better working state + */} + {/* Map - temporarily hidden until in better working state + */} diff --git a/src/ui/CustomToolbar.tsx b/src/ui/CustomToolbar.tsx index f07e34a..fb920de 100644 --- a/src/ui/CustomToolbar.tsx +++ b/src/ui/CustomToolbar.tsx @@ -726,6 +726,7 @@ export function CustomToolbar() { isSelected={tools["Transcription"].id === editor.getCurrentToolId()} /> )} + {/* Holon - temporarily hidden until in better working state {tools["Holon"] && ( )} + */} {tools["FathomMeetings"] && ( )} + {/* VideoGen - temporarily hidden until in better working state {tools["VideoGen"] && ( )} + */} + {/* Terminal (Multmux) - temporarily hidden until in better working state {tools["Multmux"] && ( )} + */} + {/* Map - temporarily hidden until in better working state {tools["Map"] && ( )} + */} {/* Refresh All ObsNotes Button */} {(() => { const allShapes = editor.getCurrentPageShapes() diff --git a/src/ui/MycelialIntelligenceBar.tsx b/src/ui/MycelialIntelligenceBar.tsx index 288a9c3..9f64460 100644 --- a/src/ui/MycelialIntelligenceBar.tsx +++ b/src/ui/MycelialIntelligenceBar.tsx @@ -473,19 +473,18 @@ type FollowUpContext = | { type: 'selection'; count: number; shapeTypes: Record } // Follow-up suggestions after transform commands +// NOTE: Arrow/connection drawing is not yet implemented, so those suggestions are removed const TRANSFORM_FOLLOWUPS: Record = { // After arranging in a row 'arrange-row': [ { label: 'make same size', prompt: 'make these the same size', icon: 'πŸ“', category: 'refine' }, { label: 'add labels', prompt: 'add a label above each shape', icon: '🏷️', category: 'expand' }, - { label: 'connect with arrows', prompt: 'draw arrows connecting these in sequence', icon: 'β†’', category: 'connect' }, { label: 'group these', prompt: 'create a frame around these shapes', icon: 'πŸ“¦', category: 'organize' }, ], // After arranging in a column 'arrange-column': [ { label: 'make same width', prompt: 'make these the same width', icon: '↔️', category: 'refine' }, { label: 'number them', prompt: 'add numbers before each item', icon: 'πŸ”’', category: 'expand' }, - { label: 'connect vertically', prompt: 'draw arrows connecting these from top to bottom', icon: '↓', category: 'connect' }, { label: 'add header', prompt: 'create a title above this column', icon: 'πŸ“', category: 'expand' }, ], // After arranging in a grid @@ -498,7 +497,6 @@ const TRANSFORM_FOLLOWUPS: Record = { // After arranging in a circle 'arrange-circle': [ { label: 'add center node', prompt: 'add a central connecting node', icon: 'β­•', category: 'expand' }, - { label: 'connect to center', prompt: 'draw lines from each to the center', icon: 'πŸ•ΈοΈ', category: 'connect' }, { label: 'label the cycle', prompt: 'add a title for this cycle diagram', icon: 'πŸ“', category: 'expand' }, ], // After aligning @@ -522,7 +520,6 @@ const TRANSFORM_FOLLOWUPS: Record = { 'distribute-horizontal': [ { label: 'align tops', prompt: 'align these to the top', icon: '⬆️', category: 'refine' }, { label: 'make same size', prompt: 'make these the same size', icon: 'πŸ“', category: 'refine' }, - { label: 'connect in sequence', prompt: 'draw arrows between these', icon: 'β†’', category: 'connect' }, ], 'distribute-vertical': [ { label: 'align left', prompt: 'align these to the left', icon: '⬅️', category: 'refine' }, @@ -548,8 +545,8 @@ const TRANSFORM_FOLLOWUPS: Record = { // After semantic clustering 'cluster-semantic': [ { label: 'label clusters', prompt: 'add a label to each cluster', icon: '🏷️', category: 'expand' }, - { label: 'connect related', prompt: 'draw connections between related clusters', icon: 'πŸ”—', category: 'connect' }, { label: 'create overview', prompt: 'create a summary of all clusters', icon: 'πŸ“Š', category: 'expand' }, + { label: 'color by group', prompt: 'color code each cluster differently', icon: '🎨', category: 'refine' }, ], } @@ -580,6 +577,7 @@ const TOOL_SPAWN_FOLLOWUPS: Record = { } // Generic follow-ups based on canvas state +// NOTE: Connection/arrow drawing is not yet implemented, so we use different suggestions const CANVAS_STATE_FOLLOWUPS = { manyShapes: [ { label: 'organize all', prompt: 'help me organize everything on this canvas', icon: 'πŸ—‚οΈ', category: 'organize' as const }, @@ -588,7 +586,7 @@ const CANVAS_STATE_FOLLOWUPS = { ], hasText: [ { label: 'summarize all', prompt: 'create a summary of all text content', icon: 'πŸ“', category: 'organize' as const }, - { label: 'find connections', prompt: 'what connections exist between my notes?', icon: 'πŸ”—', category: 'connect' as const }, + { label: 'find themes', prompt: 'what themes exist across my notes?', icon: '🎯', category: 'expand' as const }, ], hasImages: [ { label: 'describe images', prompt: 'what themes are in my images?', icon: 'πŸ–ΌοΈ', category: 'expand' as const }, @@ -1426,7 +1424,7 @@ export function MycelialIntelligenceBar() { } }, [editor, suggestedTools, spawnedToolIds]) - // Responsive layout - detect window width + // Responsive layout - detect window width and calculate available space const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1024) const isMobile = windowWidth < 640 const isNarrow = windowWidth < 768 @@ -1437,13 +1435,23 @@ export function MycelialIntelligenceBar() { return () => window.removeEventListener('resize', handleResize) }, []) + // Calculate available width between left and right menus + // Left menu (hamburger, page menu): ~140px + // Right menu (share, CryptID, settings): ~280px + // Add padding: 20px on each side + const leftMenuWidth = 140 + const rightMenuWidth = 280 + const menuPadding = 40 // 20px padding on each side + const availableWidth = windowWidth - leftMenuWidth - rightMenuWidth - menuPadding + const maxBarWidth = Math.max(200, Math.min(520, availableWidth)) // Clamp between 200-520px + // Height: taller when showing suggestion chips (single tool or 2+ selected) // Base height matches the top-right menu (~40px) for visual alignment const showSuggestions = selectedToolInfo || (selectionInfo && selectionInfo.count > 1) const collapsedHeight = showSuggestions ? 68 : 40 const maxExpandedHeight = isMobile ? 300 : 400 - // Responsive width: full width on mobile, percentage on narrow, fixed on desktop - const barWidth = isMobile ? 'calc(100% - 20px)' : isNarrow ? 'calc(100% - 120px)' : 520 + // Responsive width: dynamically sized to fit between left and right menus + const barWidth = isMobile ? 'calc(100% - 20px)' : maxBarWidth // Calculate dynamic height when expanded based on content // Header: ~45px, Input area: ~56px, padding: ~24px = ~125px fixed @@ -1466,7 +1474,7 @@ export function MycelialIntelligenceBar() { left: '50%', transform: 'translateX(-50%)', width: barWidth, - maxWidth: isMobile ? 'none' : '520px', + maxWidth: isMobile ? 'none' : `${maxBarWidth}px`, height: isExpanded ? 'auto' : collapsedHeight, minHeight: isExpanded ? minExpandedHeight : collapsedHeight, maxHeight: isExpanded ? maxExpandedHeight : collapsedHeight, diff --git a/src/ui/components.tsx b/src/ui/components.tsx index 50b38e1..c6f08a8 100644 --- a/src/ui/components.tsx +++ b/src/ui/components.tsx @@ -12,6 +12,7 @@ import CryptIDDropdown from "../components/auth/CryptIDDropdown" import StarBoardButton from "../components/StarBoardButton" import ShareBoardButton from "../components/ShareBoardButton" import { SettingsDialog } from "./SettingsDialog" +// import { VersionHistoryPanel } from "../components/history" // TODO: Re-enable when version reversion is ready import { useAuth } from "../context/AuthContext" import { PermissionLevel } from "../lib/auth/types" import { WORKER_URL } from "../constants/workerUrl" @@ -54,6 +55,7 @@ function CustomSharePanel() { const [showShortcuts, setShowShortcuts] = React.useState(false) const [showSettingsDropdown, setShowSettingsDropdown] = React.useState(false) + // const [showVersionHistory, setShowVersionHistory] = React.useState(false) // TODO: Re-enable when version reversion is ready const [showAISection, setShowAISection] = React.useState(false) const [hasApiKey, setHasApiKey] = React.useState(false) const [permissionRequestStatus, setPermissionRequestStatus] = React.useState<'idle' | 'sending' | 'sent' | 'error'>('idle') @@ -228,8 +230,8 @@ function CustomSharePanel() { } }) - // Custom tools - const customToolIds = ['VideoChat', 'ChatBox', 'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'Prompt', 'ObsidianNote', 'Transcription', 'Holon', 'FathomMeetings', 'ImageGen', 'VideoGen', 'Multmux'] + // Custom tools (VideoGen and Map temporarily hidden) + const customToolIds = ['VideoChat', 'ChatBox', 'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'Prompt', 'ObsidianNote', 'Transcription', 'Holon', 'FathomMeetings', 'ImageGen', 'Multmux'] customToolIds.forEach(toolId => { const tool = tools[toolId] if (tool?.kbd) { @@ -299,9 +301,12 @@ function CustomSharePanel() { display: 'flex', alignItems: 'center', gap: '0', - background: 'var(--color-muted-1)', + background: isDarkMode ? '#2d2d2d' : '#f3f4f6', + backgroundColor: isDarkMode ? '#2d2d2d' : '#f3f4f6', + backdropFilter: 'none', + opacity: 1, borderRadius: '20px', - border: '1px solid var(--color-panel-contrast)', + border: `1px solid ${isDarkMode ? '#404040' : '#e5e7eb'}`, padding: '4px 6px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)', }}> @@ -383,47 +388,67 @@ function CustomSharePanel() { position: 'fixed', top: settingsDropdownPos.top, right: settingsDropdownPos.right, - minWidth: '200px', + minWidth: '220px', maxHeight: '60vh', overflowY: 'auto', overflowX: 'hidden', background: 'var(--color-panel)', + backgroundColor: 'var(--color-panel)', border: '1px solid var(--color-panel-contrast)', borderRadius: '8px', - boxShadow: '0 4px 20px rgba(0,0,0,0.2)', + boxShadow: isDarkMode ? '0 4px 20px rgba(0,0,0,0.5)' : '0 4px 20px rgba(0,0,0,0.25)', zIndex: 99999, padding: '8px 0', pointerEvents: 'auto', + backdropFilter: 'none', + opacity: 1, + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', }} onWheel={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} > - {/* Board Permission Display */} -
+ {/* Board Permission Section */} +
+ {/* Section Header */}
- - πŸ” - Board Permission - + πŸ” + Board Permission - {PERMISSION_CONFIG[currentPermission].icon} {PERMISSION_CONFIG[currentPermission].label} + {PERMISSION_CONFIG[currentPermission].label}
- {/* Permission levels with request buttons */} -
+ {/* Permission levels - indented to show hierarchy */} +
+ + Access Levels + {(['view', 'edit', 'admin'] as PermissionLevel[]).map((level) => { const config = PERMISSION_CONFIG[level] const isCurrent = currentPermission === level @@ -439,23 +464,35 @@ function CustomSharePanel() { display: 'flex', alignItems: 'center', justifyContent: 'space-between', - padding: '6px 8px', + padding: '8px 10px', borderRadius: '6px', - background: isCurrent ? `${config.color}15` : 'transparent', - border: isCurrent ? `1px solid ${config.color}40` : '1px solid transparent', + background: isCurrent ? `${config.color}15` : 'var(--color-panel)', + border: isCurrent ? `2px solid ${config.color}` : '1px solid var(--color-panel-contrast)', + transition: 'all 0.15s ease', }} > - {config.icon} + {config.icon} {config.label} - {isCurrent && (current)} + {isCurrent && ( + + Current + + )} {canRequest && ( @@ -463,15 +500,24 @@ function CustomSharePanel() { onClick={() => handleRequestPermission(level)} disabled={permissionRequestStatus === 'sending'} style={{ - padding: '3px 8px', + padding: '4px 10px', fontSize: '10px', - fontWeight: 500, + fontWeight: 600, borderRadius: '4px', - border: 'none', - background: config.color, - color: 'white', + border: `1px solid ${config.color}`, + background: 'transparent', + color: config.color, cursor: permissionRequestStatus === 'sending' ? 'wait' : 'pointer', opacity: permissionRequestStatus === 'sending' ? 0.6 : 1, + transition: 'all 0.15s ease', + }} + onMouseEnter={(e) => { + e.currentTarget.style.background = config.color + e.currentTarget.style.color = 'white' + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'transparent' + e.currentTarget.style.color = config.color }} > {permissionRequestStatus === 'sending' ? '...' : 'Request'} @@ -485,10 +531,10 @@ function CustomSharePanel() { {/* Request status message */} {requestMessage && (

Sign in to request higher permissions

)}
-
+
- {/* Dark mode toggle */} - + + 🎨 + Appearance + -
+ {/* Toggle Switch */} + +
+
- {/* AI Models expandable section */} +
+ + {/* AI Models Accordion */}
{showAISection && ( -
-

- Local models are free. Cloud models require API keys. +

+

+ πŸ’‘ Local models are free. Cloud models require API keys.

- {AI_TOOLS.map((tool) => ( -
- - {tool.icon} - {tool.name} - - + {AI_TOOLS.map((tool) => ( +
- {tool.model} - -
- ))} + + {tool.icon} + {tool.name} + + + {tool.model} + +
+ ))} +
)}
+
+ + {/* Version Reversion - Coming Soon */} +
+ {/* Section Header - matches other headers */} +
+ πŸ• + Version Reversion +
+ + {/* Coming Soon Button */} + +
+
, document.body @@ -723,13 +884,16 @@ function CustomSharePanel() { maxHeight: '50vh', overflowY: 'auto', overflowX: 'hidden', - background: 'var(--color-panel)', + background: 'var(--color-panel, #ffffff)', + backgroundColor: 'var(--color-panel, #ffffff)', border: '1px solid var(--color-panel-contrast)', borderRadius: '8px', - boxShadow: '0 4px 20px rgba(0,0,0,0.2)', + boxShadow: '0 4px 20px rgba(0,0,0,0.25)', zIndex: 99999, padding: '10px 0', pointerEvents: 'auto', + backdropFilter: 'none', + opacity: 1, }} onWheel={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} @@ -808,6 +972,22 @@ function CustomSharePanel() { document.body )} + {/* Version Reversion Panel - Coming Soon */} + {/* TODO: Re-enable when version history backend is fully tested + {showVersionHistory && createPortal( + setShowVersionHistory(false)} + onRevert={(hash) => { + console.log('Reverted to version:', hash) + window.location.reload() + }} + isDarkMode={isDarkMode} + />, + document.body + )} + */} +
) } @@ -849,7 +1029,7 @@ export const components: TLComponents = { tools["Holon"], tools["FathomMeetings"], tools["ImageGen"], - tools["VideoGen"], + // tools["VideoGen"], // Temporarily hidden tools["Multmux"], // MycelialIntelligence moved to permanent floating bar ].filter(tool => tool && tool.kbd) diff --git a/src/ui/overrides.tsx b/src/ui/overrides.tsx index 7c84c0a..23e2f06 100644 --- a/src/ui/overrides.tsx +++ b/src/ui/overrides.tsx @@ -222,6 +222,14 @@ export const overrides: TLUiOverrides = { readonlyOk: true, onSelect: () => editor.setCurrentTool("VideoGen"), }, + Drawfast: { + id: "Drawfast", + icon: "tool-pencil", + label: "Drawfast (AI Sketch)", + kbd: "ctrl+shift+d", + readonlyOk: true, + onSelect: () => editor.setCurrentTool("Drawfast"), + }, Multmux: { id: "Multmux", icon: "terminal", diff --git a/src/utils/toolSpawner.ts b/src/utils/toolSpawner.ts index ba1f821..a587483 100644 --- a/src/utils/toolSpawner.ts +++ b/src/utils/toolSpawner.ts @@ -14,6 +14,7 @@ const TOOL_DIMENSIONS: Record = { Prompt: { w: 300, h: 500 }, ImageGen: { w: 400, h: 450 }, VideoGen: { w: 400, h: 350 }, + Drawfast: { w: 512, h: 512 }, ChatBox: { w: 400, h: 500 }, Markdown: { w: 400, h: 400 }, ObsNote: { w: 280, h: 200 }, diff --git a/worker/boardPermissions.ts b/worker/boardPermissions.ts index fc89eb2..d94c09a 100644 --- a/worker/boardPermissions.ts +++ b/worker/boardPermissions.ts @@ -64,12 +64,13 @@ export async function getEffectivePermission( }; } - // Check for explicit permission + // Check for explicit user-specific permission const explicitPerm = await db.prepare( 'SELECT * FROM board_permissions WHERE board_id = ? AND user_id = ?' ).bind(boardId, userId).first(); if (explicitPerm) { + // User has a specific permission set - use it (could be view, edit, or admin) return { permission: explicitPerm.permission, isOwner: false, @@ -77,14 +78,11 @@ export async function getEffectivePermission( }; } - // Fall back to default permission, but authenticated users get at least 'edit' - // (unless board explicitly restricts to view-only) - const defaultPerm = board.default_permission as PermissionLevel; - - // For most boards, authenticated users can edit - // Board owners can set default_permission to 'view' to restrict this + // No explicit permission for this user + // Authenticated users get 'edit' by default + // (Board's default_permission only affects anonymous users with access tokens) return { - permission: defaultPerm === 'view' ? 'view' : 'edit', + permission: 'edit', isOwner: false, boardExists: true }; diff --git a/worker/worker.ts b/worker/worker.ts index e2b5616..8443cf1 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -269,6 +269,45 @@ const router = AutoRouter({ }) }) + // Version History API - forward to Durable Object + .get("/room/:roomId/history", async (request, env) => { + const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId) + const room = env.AUTOMERGE_DURABLE_OBJECT.get(id) + return room.fetch(request.url, { + headers: request.headers, + method: "GET", + }) + }) + + .get("/room/:roomId/snapshot/:hash", async (request, env) => { + const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId) + const room = env.AUTOMERGE_DURABLE_OBJECT.get(id) + return room.fetch(request.url, { + headers: request.headers, + method: "GET", + }) + }) + + .post("/room/:roomId/diff", async (request, env) => { + const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId) + const room = env.AUTOMERGE_DURABLE_OBJECT.get(id) + return room.fetch(request.url, { + method: "POST", + body: request.body, + headers: request.headers, + }) + }) + + .post("/room/:roomId/revert", async (request, env) => { + const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId) + const room = env.AUTOMERGE_DURABLE_OBJECT.get(id) + return room.fetch(request.url, { + method: "POST", + body: request.body, + headers: request.headers, + }) + }) + .post("/daily/rooms", async (req) => { const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]