diff --git a/backend/app/controllers/image.py b/backend/app/controllers/image.py index ef648bb..dbdff90 100644 --- a/backend/app/controllers/image.py +++ b/backend/app/controllers/image.py @@ -7,11 +7,8 @@ from app.services.image import ImageService from app.services.project import ProjectService from app.utils.database import db_client -from app.utils.storage import ( - download_and_upload_image_from_url, - save_image_pair_to_db, - upload_image_to_storage, -) +from app.utils.storage import (download_and_upload_image_from_url, + save_image_pair_to_db, upload_image_to_storage) log = logging.getLogger(__name__) @@ -178,22 +175,26 @@ async def generate_image( ) log.info("Image generation completed successfully") - # Add background task to generate and save project icon (on first generation) - background_tasks.add_task( - generate_and_save_project_icon, - authorization=authorization, - project_id=input.project_id, - ) - - # Add background task to save images to database - background_tasks.add_task( - save_images_to_database, - authorization=authorization, - project_id=input.project_id, - input_image_data=input.image_data, - output_image_data=response.image_data, - prompt_text=input.prompt, - ) + # Only add background tasks if save_data is True + if input.save_data: + # Add background task to generate and save project icon (on first generation) + background_tasks.add_task( + generate_and_save_project_icon, + authorization=authorization, + project_id=input.project_id, + ) + + # Add background task to save images to database + background_tasks.add_task( + save_images_to_database, + authorization=authorization, + project_id=input.project_id, + input_image_data=input.image_data, + output_image_data=response.image_data, + prompt_text=input.prompt, + ) + else: + log.info("Skipping save operations as save_data is False") return response except ValueError as e: diff --git a/backend/app/controllers/project.py b/backend/app/controllers/project.py index 1b717da..17f7db5 100644 --- a/backend/app/controllers/project.py +++ b/backend/app/controllers/project.py @@ -2,14 +2,9 @@ from fastapi import APIRouter, Header, HTTPException -from app.models.project import ( - IconGenerationRequest, - IconGenerationResponse, - Project, - ProjectCreateRequest, - ProjectListResponse, - ProjectUpdateRequest, -) +from app.models.project import (IconGenerationRequest, IconGenerationResponse, + Project, ProjectCreateRequest, + ProjectListResponse, ProjectUpdateRequest) from app.services.project import ProjectService from app.utils.database import db_client diff --git a/backend/app/models/image.py b/backend/app/models/image.py index 28591a4..d84a705 100644 --- a/backend/app/models/image.py +++ b/backend/app/models/image.py @@ -9,13 +9,18 @@ class ImageGenerationRequest(BaseModel): default=None, description="Optional base64 encoded image data to use as input for image generation.", ) - project_id: str = Field( - description="The project ID to associate with this image pair." + project_id: Optional[str] = Field( + default=None, + description="Optional project ID to associate with this image pair. Required when save_data is True.", ) type: Literal["generate", "edit"] = Field( default="generate", description="The type of operation: 'generate' for new images or 'edit' for modifying existing images.", ) + save_data: bool = Field( + default=True, + description="Whether to save the generated images to database and generate project icon. Default is True.", + ) class ImageGenerationResponse(BaseModel): diff --git a/backend/app/services/project.py b/backend/app/services/project.py index 47bd978..0ade7c9 100644 --- a/backend/app/services/project.py +++ b/backend/app/services/project.py @@ -6,13 +6,9 @@ from openai import OpenAI from supabase._async.client import AsyncClient as Client -from app.models.project import ( - IconGenerationRequest, - IconGenerationResponse, - Project, - ProjectCreateRequest, - ProjectUpdateRequest, -) +from app.models.project import (IconGenerationRequest, IconGenerationResponse, + Project, ProjectCreateRequest, + ProjectUpdateRequest) log = logging.getLogger(__name__) @@ -250,7 +246,8 @@ async def generate_3d_icon( try: # Construct the full prompt with style modifiers - full_prompt = f"The following is a text prompt or a conversation about a 2 or 3 word topic: {request.prompt}, Draw a 3D smooth icon png with the following style: {request.style}. Also make sure you don't include text in the image." + # Keep it simple and direct for better AI understanding + full_prompt = f"A clean 3D icon representing '{request.prompt}'. Style: {request.style}. No text or labels in the image." # Define callback to log queue updates def on_queue_update(update): diff --git a/backend/app/utils/database.py b/backend/app/utils/database.py index 81b4619..cb37e76 100644 --- a/backend/app/utils/database.py +++ b/backend/app/utils/database.py @@ -3,7 +3,6 @@ import uuid from supabase import AsyncClientOptions - # from supabase import AsyncClientOptions from supabase._async.client import AsyncClient as Client from supabase._async.client import create_client diff --git a/frontend/src/actions/image.ts b/frontend/src/actions/image.ts index 38e58a8..0236be1 100644 --- a/frontend/src/actions/image.ts +++ b/frontend/src/actions/image.ts @@ -3,8 +3,9 @@ export interface GenerateImageRequest { prompt: string; image_data?: string | null; - project_id: string; + project_id?: string; type: 'generate' | 'edit'; + save_data?: boolean; } export interface GenerateImageResponse { diff --git a/frontend/src/app/create/page.tsx b/frontend/src/app/create/page.tsx new file mode 100644 index 0000000..8c67ed2 --- /dev/null +++ b/frontend/src/app/create/page.tsx @@ -0,0 +1,740 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { generateImage } from '@/actions/image'; +import { Tldraw, createShapeId } from 'tldraw'; +import 'tldraw/tldraw.css'; + +import { BeforeAfterSlider } from '@/components/canvas/before-after-slider'; +import { ImageSidebarSimple } from '@/components/canvas/image-sidebar-simple'; + +// Extend Window interface for Speech Recognition API +interface WindowWithSpeechRecognition extends Window { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + SpeechRecognition?: new () => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + webkitSpeechRecognition?: new () => any; +} + +export default function CreatePage() { + const [generatedImage, setGeneratedImage] = useState(null); + const [isGenerating, setIsGenerating] = useState(false); + const [isListening, setIsListening] = useState(false); + const [mode, setMode] = useState<'agent' | 'ask'>('agent'); // Explicit mode tracking + const [agentTranscript, setAgentTranscript] = useState(''); // Voice transcript for Agent Mode + const [askPrompt, setAskPrompt] = useState(''); // Text input for Ask Mode + const [error, setError] = useState(null); + const [frameId, setFrameId] = useState(null); + const [imageUsed, setImageUsed] = useState(false); + const [showSlider, setShowSlider] = useState(false); + const [beforeImage, setBeforeImage] = useState(null); + const [afterImage, setAfterImage] = useState(null); + const [frameBounds, setFrameBounds] = useState<{ + x: number; + y: number; + width: number; + height: number; + } | null>(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const recognitionRef = useRef(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const editorRef = useRef(null); + const autoGenerateTimerRef = useRef(null); + const agentTranscriptRef = useRef(''); + const handleGenerateRef = useRef<(() => Promise) | null>(null); + const isListeningRef = useRef(false); + + // Keep agent transcript ref in sync with agent transcript state + useEffect(() => { + console.log('[TRANSCRIPT-REF] Updating agentTranscriptRef to:', agentTranscript); + agentTranscriptRef.current = agentTranscript; + }, [agentTranscript]); + + // Keep isListening ref in sync with isListening state + useEffect(() => { + isListeningRef.current = isListening; + }, [isListening]); + + useEffect(() => { + // Check if browser supports Speech Recognition + if (typeof window !== 'undefined') { + const windowWithSpeech = window as unknown as WindowWithSpeechRecognition; + const SpeechRecognition = + windowWithSpeech.SpeechRecognition || windowWithSpeech.webkitSpeechRecognition; + + if (SpeechRecognition) { + recognitionRef.current = new SpeechRecognition(); + recognitionRef.current.continuous = true; + recognitionRef.current.interimResults = true; + recognitionRef.current.lang = 'en-US'; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + recognitionRef.current.onresult = (event: any) => { + let finalTranscript = ''; + + for (let i = event.resultIndex; i < event.results.length; i++) { + const transcriptPiece = event.results[i][0].transcript; + if (event.results[i].isFinal) { + finalTranscript += transcriptPiece + ' '; + } + } + + if (finalTranscript) { + console.log('[SPEECH] Final transcript received:', finalTranscript); + // Voice input is only for Agent Mode + setAgentTranscript((prev) => { + const newTranscript = prev + finalTranscript; + console.log('[SPEECH] Updated agentTranscript:', newTranscript); + return newTranscript; + }); + } + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + recognitionRef.current.onerror = (event: any) => { + console.error('Speech recognition error:', event.error); + setIsListening(false); + }; + + recognitionRef.current.onend = () => { + console.log('[SPEECH] Recognition ended - checking if should restart...'); + console.log('[SPEECH] isListeningRef.current:', isListeningRef.current); + + // If we're still supposed to be listening (user didn't manually stop), + // restart the recognition to keep it going + if (isListeningRef.current) { + console.log('[SPEECH] Auto-restarting recognition to continue listening'); + try { + recognitionRef.current?.start(); + } catch (err) { + console.error('[SPEECH] Failed to restart recognition:', err); + setIsListening(false); + } + } else { + console.log('[SPEECH] User stopped listening, not restarting'); + setIsListening(false); + } + }; + } + } + + return () => { + if (recognitionRef.current) { + recognitionRef.current.stop(); + } + }; + }, []); + + // Auto-generate in Agent Mode after 20 seconds of listening + useEffect(() => { + // Only set timer if in Agent Mode and listening has started + if (mode === 'agent' && isListening && editorRef.current && frameId) { + console.log('[AUTO-GEN] Agent Mode listening started: Setting 20-second timer'); + autoGenerateTimerRef.current = setTimeout(() => { + // In Agent Mode, use the agent transcript ref + const agentTranscript = agentTranscriptRef.current.trim(); + console.log('[AUTO-GEN] Timer fired! Agent transcript:', agentTranscript); + console.log('[AUTO-GEN] handleGenerateRef exists?', !!handleGenerateRef.current); + + if (agentTranscript && handleGenerateRef.current) { + console.log('[AUTO-GEN] Triggering auto-generate with transcript:', agentTranscript); + handleGenerateRef.current(); + } else { + console.log('[AUTO-GEN] Skipping - no transcript or handler'); + } + }, 25000); // 20 seconds + } else { + console.log( + '[AUTO-GEN] Not setting timer. mode:', + mode, + 'isListening:', + isListening, + 'frameId:', + !!frameId, + 'editor:', + !!editorRef.current, + ); + } + + // Cleanup on unmount or when listening stops + return () => { + if (autoGenerateTimerRef.current) { + console.log('[AUTO-GEN] Clearing timer due to cleanup'); + clearTimeout(autoGenerateTimerRef.current); + autoGenerateTimerRef.current = null; + } + }; + }, [mode, isListening, frameId]); + + const toggleListening = () => { + if (!recognitionRef.current) { + alert('Speech recognition is not supported in your browser'); + return; + } + + if (isListening) { + console.log('[TOGGLE] Stopping listening'); + recognitionRef.current.stop(); + setIsListening(false); + } else { + console.log('[TOGGLE] Starting listening in Agent Mode'); + // Clear agent transcript when starting new listening session + setAgentTranscript(''); + console.log('[TOGGLE] Cleared agentTranscript'); + recognitionRef.current.start(); + setIsListening(true); + } + }; + + // Helper to capture current canvas state as data URL + const captureCanvasSnapshot = useCallback(async (): Promise => { + if (!editorRef.current || !frameId) return null; + + const editor = editorRef.current; + const frame = editor.getShape(frameId); + if (!frame) return null; + + const childShapeIds = editor.getSortedChildIdsForParent(frameId).filter((id: string) => { + const shape = editor.getShape(id); + return shape && !shape.isLocked; + }); + + const shapeIdsToExport = [frameId, ...childShapeIds]; + + const { blob } = await editor.toImage(shapeIdsToExport, { + format: 'png', + background: true, + padding: 0, + }); + + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + }); + }, [frameId]); + + // Helper to create a preview with the new image + const createAfterPreview = useCallback( + async (imageBase64: string): Promise => { + if (!editorRef.current || !frameId) return null; + + const editor = editorRef.current; + const frame = editor.getShape(frameId); + if (!frame) return null; + + // Store current state to restore later + const dataUrl = `data:image/png;base64,${imageBase64}`; + + // Get image dimensions + const img = document.createElement('img'); + const imageLoadPromise = new Promise<{ width: number; height: number }>((resolve, reject) => { + img.onload = () => resolve({ width: img.width, height: img.height }); + img.onerror = reject; + }); + img.src = dataUrl; + const { width: imageWidth, height: imageHeight } = await imageLoadPromise; + + // Create temporary asset and shape + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tempAssetId = `asset:${createShapeId()}` as any; + const tempShapeId = createShapeId(); + + editor.createAssets([ + { + id: tempAssetId, + type: 'image', + typeName: 'asset', + props: { + name: 'preview.png', + src: dataUrl, + w: imageWidth, + h: imageHeight, + mimeType: 'image/png', + isAnimated: false, + }, + meta: {}, + }, + ]); + + const frameProps = frame.props as { w: number; h: number }; + const maxWidth = frameProps.w * 0.8; + const maxHeight = frameProps.h * 0.8; + const scale = Math.min(maxWidth / imageWidth, maxHeight / imageHeight, 1); + const scaledWidth = imageWidth * scale; + const scaledHeight = imageHeight * scale; + const imageX = (frameProps.w - scaledWidth) / 2; + const imageY = (frameProps.h - scaledHeight) / 2; + + editor.createShapes([ + { + id: tempShapeId, + type: 'image', + x: imageX, + y: imageY, + parentId: frameId, + props: { + w: scaledWidth, + h: scaledHeight, + assetId: tempAssetId, + }, + }, + ]); + + // Wait a tick for the shape to render + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Capture the preview + const preview = await captureCanvasSnapshot(); + + // Clean up temporary shape and asset + editor.deleteShapes([tempShapeId]); + editor.deleteAssets([tempAssetId]); + + return preview; + }, + [frameId, captureCanvasSnapshot], + ); + + // Load test image as base64 + const loadTestImage = useCallback(async (): Promise => { + const response = await fetch('/test.png'); + const blob = await response.blob(); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => { + const base64 = (reader.result as string).split(',')[1]; + resolve(base64); + }; + reader.readAsDataURL(blob); + }); + }, []); + + // Get frame bounds in screen coordinates + const getFrameBounds = useCallback(() => { + if (!editorRef.current || !frameId) return null; + + const editor = editorRef.current; + const frame = editor.getShape(frameId); + if (!frame) return null; + + // Get the frame's page bounds (in canvas coordinates) + const bounds = editor.getShapePageBounds(frameId); + + if (!bounds) return null; + + // Convert canvas coordinates to screen coordinates + const { x, y } = editor.pageToScreen({ x: bounds.x, y: bounds.y }); + const bottomRight = editor.pageToScreen({ x: bounds.maxX, y: bounds.maxY }); + + return { + x, + y, + width: bottomRight.x - x, + height: bottomRight.y - y, + }; + }, [frameId]); + + // Show the before/after slider animation + const handleAcceptImage = useCallback(async () => { + if (!editorRef.current || !frameId) { + setError('Canvas not ready'); + return; + } + + try { + // If no generated image, use test.png for testing + let imageToUse = generatedImage; + if (!imageToUse) { + const testImageBase64 = await loadTestImage(); + setGeneratedImage(testImageBase64); + imageToUse = testImageBase64; + } + + // Capture before state first + const before = await captureCanvasSnapshot(); + if (!before) { + setError('Failed to capture canvas state'); + return; + } + + // Get frame bounds for positioning the slider + const bounds = getFrameBounds(); + setFrameBounds(bounds); + + // Show the before image immediately to hide canvas operations + setBeforeImage(before); + setAfterImage(null); // Will be set shortly + setShowSlider(true); + + // Small delay to ensure overlay is shown + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Create after preview using the image we have (happens behind overlay now) + const after = await createAfterPreview(imageToUse); + if (!after) { + setError('Failed to create preview'); + setShowSlider(false); + return; + } + + // Update with the after image + setAfterImage(after); + } catch (err) { + console.error('Error preparing slider:', err); + setError(err instanceof Error ? err.message : 'Failed to prepare preview'); + setShowSlider(false); + } + }, [ + frameId, + generatedImage, + loadTestImage, + captureCanvasSnapshot, + createAfterPreview, + getFrameBounds, + ]); + + // Actually place the image on the canvas (called after slider completes) + const placeImageOnCanvas = useCallback(async () => { + if (!editorRef.current || !frameId || !generatedImage) return; + + try { + const editor = editorRef.current; + const frame = editor.getShape(frameId); + if (!frame) return; + + // CLEAR ALL EXISTING SHAPES IN THE FRAME FIRST + const childShapeIds = editor.getSortedChildIdsForParent(frameId); + if (childShapeIds.length > 0) { + editor.deleteShapes(childShapeIds); + } + + const dataUrl = `data:image/png;base64,${generatedImage}`; + + const img = document.createElement('img'); + const imageLoadPromise = new Promise<{ width: number; height: number }>((resolve, reject) => { + img.onload = () => resolve({ width: img.width, height: img.height }); + img.onerror = reject; + }); + img.src = dataUrl; + const { width: imageWidth, height: imageHeight } = await imageLoadPromise; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const assetId = `asset:${createShapeId()}` as any; + + editor.createAssets([ + { + id: assetId, + type: 'image', + typeName: 'asset', + props: { + name: 'generated.png', + src: dataUrl, + w: imageWidth, + h: imageHeight, + mimeType: 'image/png', + isAnimated: false, + }, + meta: {}, + }, + ]); + + const frameProps = frame.props as { w: number; h: number }; + const maxWidth = frameProps.w * 0.8; + const maxHeight = frameProps.h * 0.8; + const scale = Math.min(maxWidth / imageWidth, maxHeight / imageHeight, 1); + const scaledWidth = imageWidth * scale; + const scaledHeight = imageHeight * scale; + const imageX = (frameProps.w - scaledWidth) / 2; + const imageY = (frameProps.h - scaledHeight) / 2; + + const imageShapeId = createShapeId(); + editor.createShapes([ + { + id: imageShapeId, + type: 'image', + x: imageX, + y: imageY, + parentId: frameId, + props: { + w: scaledWidth, + h: scaledHeight, + assetId: assetId, + }, + }, + ]); + + setImageUsed(true); + setShowSlider(false); + console.log('Image placed in frame'); + } catch (err) { + console.error('Error placing image:', err); + setError(err instanceof Error ? err.message : 'Failed to place image'); + } + }, [frameId, generatedImage]); + + const handleRejectImage = useCallback(() => { + setImageUsed(true); + console.log('Image rejected'); + }, []); + + // Keyboard shortcuts for Accept/Reject + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // If slider is showing, ESC cancels it + if (showSlider && e.key === 'Escape') { + e.preventDefault(); + setShowSlider(false); + return; + } + + // Only trigger if buttons are visible + if (imageUsed || !generatedImage) return; + + if (e.key === 'Tab') { + e.preventDefault(); + handleAcceptImage(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleRejectImage(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [imageUsed, generatedImage, showSlider, handleAcceptImage, handleRejectImage]); + + const exportCanvasImage = useCallback(async (): Promise => { + console.log('exportCanvasImage: Starting export...'); + console.log('exportCanvasImage: editorRef.current:', !!editorRef.current); + console.log('exportCanvasImage: frameId:', frameId); + + if (!editorRef.current || !frameId) { + console.log('exportCanvasImage: No editor or frameId, returning null'); + return null; + } + + const editor = editorRef.current; + + // Debug: Check all shapes on the current page + const allShapeIds = Array.from(editor.getCurrentPageShapeIds()); + console.log('exportCanvasImage: All shape IDs on page:', allShapeIds); + console.log('exportCanvasImage: Looking for frameId:', frameId); + + const frame = editor.getShape(frameId); + console.log('exportCanvasImage: frame object:', frame); + + if (!frame) { + console.log('exportCanvasImage: No frame found, returning null'); + console.log('exportCanvasImage: This might be a timing issue or frameId mismatch'); + return null; + } + + console.log('exportCanvasImage: Frame found successfully, type:', frame.type); + + // Get all shapes that are children of the frame + const childShapeIds = editor.getSortedChildIdsForParent(frameId).filter((id: string) => { + const shape = editor.getShape(id); + return shape && !shape.isLocked; + }); + + console.log('exportCanvasImage: childShapeIds count:', childShapeIds.length); + + // Include the frame itself and all its children for export + const shapeIdsToExport = [frameId, ...childShapeIds]; + + console.log('exportCanvasImage: shapeIdsToExport count:', shapeIdsToExport.length); + + // Export only the frame and its contents to PNG + // Note: shapeIdsToExport will always have at least the frame itself + const { blob } = await editor.toImage(shapeIdsToExport, { + format: 'png', + background: true, + padding: 0, + }); + + console.log('exportCanvasImage: Blob created, size:', blob.size); + + // Convert blob to base64 + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + const base64data = reader.result as string; + // Remove the data:image/png;base64, prefix + const base64 = base64data.split(',')[1]; + console.log('exportCanvasImage: Base64 created, length:', base64?.length || 0); + resolve(base64); + }; + reader.onerror = (error) => { + console.error('exportCanvasImage: FileReader error:', error); + reject(error); + }; + reader.readAsDataURL(blob); + }); + }, [frameId]); + + const handleGenerate = useCallback(async () => { + // Get the appropriate prompt based on mode + const prompt = mode === 'agent' ? agentTranscript : askPrompt; + + if (!prompt.trim()) { + setError(mode === 'agent' ? 'Please provide a voice prompt first' : 'Please enter a prompt'); + return; + } + + setIsGenerating(true); + setError(null); + + try { + // Always export canvas image - needed for both generate and edit modes + console.log('Exporting canvas image...'); + const canvasImageData = await exportCanvasImage(); + console.log( + 'Canvas image data:', + canvasImageData ? `${canvasImageData.length} chars` : 'null', + ); + + // Check if canvas has content to determine request type + const editor = editorRef.current; + const hasContent = frameId && editor?.getSortedChildIdsForParent(frameId).length > 0; + const requestType = hasContent ? 'edit' : 'generate'; + console.log('Request type:', requestType, '(mode:', mode, ', hasContent:', hasContent, ')'); + + const data = await generateImage({ + prompt: prompt, + image_data: canvasImageData, + type: requestType, + save_data: false, // Don't save to database for create page + }); + + setGeneratedImage(data.image_data); + setImageUsed(false); // Reset when new image is generated + + if (data.text_response) { + console.log('Model response:', data.text_response); + } + } catch (err) { + console.error('Error generating image:', err); + setError(err instanceof Error ? err.message : 'Failed to generate image'); + } finally { + setIsGenerating(false); + } + }, [mode, agentTranscript, askPrompt, exportCanvasImage, frameId]); + + // Keep handleGenerate ref in sync + useEffect(() => { + handleGenerateRef.current = handleGenerate; + }, [handleGenerate]); + + return ( +
+ {/* Left Side - Drawing Canvas */} +
+
+
+ Try DrawDash +
+
+
+ { + editorRef.current = editor; + + // Check if a frame already exists (to prevent duplicates in React Strict Mode) + const existingShapes = Array.from(editor.getCurrentPageShapeIds()); + const existingFrame = existingShapes.find((id) => { + const shape = editor.getShape(id); + return ( + shape?.type === 'frame' && + (shape.props as { name?: string })?.name === 'Drawing Area' + ); + }); + + if (existingFrame) { + console.log('Found existing frame:', existingFrame); + setFrameId(existingFrame); + editor.zoomToFit(); + return; + } + + // Create a centered frame for drawing + console.log('Creating new frame'); + const { width, height } = editor.getViewportPageBounds(); + const frameWidth = Math.min(800, width * 0.6); + const frameHeight = Math.min(600, height * 0.6); + const x = (width - frameWidth) / 2; + const y = (height - frameHeight) / 2; + + const frameShapeId = createShapeId(); + editor.createShapes([ + { + id: frameShapeId, + type: 'frame', + x, + y, + props: { + w: frameWidth, + h: frameHeight, + name: 'Drawing Area', + }, + }, + ]); + + setFrameId(frameShapeId); + + // Zoom to fit the frame + editor.zoomToFit(); + }} + /> +
+
+ + {/* Right Sidebar - Generated Image & Controls */} + { + setMode(newMode); + // Stop listening when switching modes + if (isListening) { + recognitionRef.current?.stop(); + setIsListening(false); + } + }} + onTranscriptChange={(value) => { + // Update the appropriate state based on current mode + if (mode === 'agent') { + setAgentTranscript(value); + } else { + setAskPrompt(value); + } + }} + onToggleListening={toggleListening} + onGenerate={handleGenerate} + onAcceptImage={handleAcceptImage} + onRejectImage={handleRejectImage} + canvasReady={!!(frameId && editorRef.current)} + /> + + {/* Before/After Slider Overlay */} + {showSlider && beforeImage && ( + setShowSlider(false)} + frameBounds={frameBounds || undefined} + /> + )} +
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 6ee316b..f95a706 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -8,12 +8,12 @@ export default function Home() { const router = useRouter(); useEffect(() => { - router.push('/projects'); + router.push('/create'); }, [router]); return (
-

Redirecting to projects...

+

Redirecting to create...

); } diff --git a/frontend/src/components/canvas/image-sidebar-simple.tsx b/frontend/src/components/canvas/image-sidebar-simple.tsx new file mode 100644 index 0000000..a8dec39 --- /dev/null +++ b/frontend/src/components/canvas/image-sidebar-simple.tsx @@ -0,0 +1,291 @@ +'use client'; + +import Image from 'next/image'; + +import { useEffect, useState } from 'react'; + +import { Binoculars, Brain, Mic, Paintbrush } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +interface ImageSidebarSimpleProps { + generatedImage: string | null; + imageUsed: boolean; + transcript: string; + isListening: boolean; + isGenerating: boolean; + error: string | null; + mode: 'agent' | 'ask'; + onModeChange: (mode: 'agent' | 'ask') => void; + onTranscriptChange: (value: string) => void; + onToggleListening: () => void; + onGenerate: () => void; + onAcceptImage: () => void; + onRejectImage: () => void; + canvasReady: boolean; +} + +export function ImageSidebarSimple({ + generatedImage, + imageUsed, + transcript, + isListening, + isGenerating, + error, + mode, + onModeChange, + onTranscriptChange, + onToggleListening, + onGenerate, + onAcceptImage, + onRejectImage, + canvasReady, +}: ImageSidebarSimpleProps) { + const [isThinking, setIsThinking] = useState(false); + + // Handle thinking -> sketching transition + useEffect(() => { + if (isGenerating) { + setIsThinking(true); + const timer = setTimeout(() => { + setIsThinking(false); + }, 3000); + return () => clearTimeout(timer); + } else { + setIsThinking(false); + } + }, [isGenerating]); + + return ( + <> + +
+ {/* Header - Empty for simple version */} +
+ {/* Intentionally left empty - no navigation links */} +
+ +
+ {/* Generated Image Display */} +
+
+ {generatedImage ? ( + Generated + ) : ( + No Diagram generated yet + )} +
+ {!imageUsed && generatedImage && ( +
+ + +
+ )} +
+ + {/* Divider */} +
+ + {/* Tabs for Agent Mode and Ask Mode */} + onModeChange(value as 'agent' | 'ask')} + className="flex flex-col gap-4" + > + + Agent Mode + Ask Mode + + + + {/* Agent Mode Description */} +
+

+ Agent mode listens to your explanations and proactively suggests diagram changes. +

+
+ + {/* Microphone Control */} +
+ + {/* Waveform Animation or Start Message */} + {isListening ? ( +
+
+
+
+
+ ) : ( + Press to start + )} +
+ + {/* Live Transcript Display */} + {isListening && ( +
+
+ Live Transcript + {isListening && !isGenerating && ( +
+ + observing +
+ )} + {isGenerating && isThinking && ( +
+ + thinking +
+ )} + {isGenerating && !isThinking && ( +
+ + sketching +
+ )} +
+
+ {transcript} +
+
+ )} + + {/* Error Display */} + {error && ( +
+ {error} +
+ )} +
+ + + {/* Ask Mode Description */} +
+

+ Ask mode lets you type a prompt, then generate/edit the diagram manually. +

+
+ + {/* Text Input */} +
+ + onTranscriptChange(e.target.value)} + className="w-full border-0 bg-white text-black shadow-none focus-visible:ring-0" + /> +
+ + {/* Error Display */} + {error && ( +
+ {error} +
+ )} + + {/* Generate Button */} + +
+
+ + {/* Spacer */} +
+
+
+ + ); +} diff --git a/frontend/src/components/shared/sidebar.tsx b/frontend/src/components/shared/sidebar.tsx index a8b827a..debc9d0 100644 --- a/frontend/src/components/shared/sidebar.tsx +++ b/frontend/src/components/shared/sidebar.tsx @@ -113,6 +113,11 @@ const SidebarView: React.FC = ({ children }) => { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); + // Hide sidebar for /create route + if (pathname === '/create') { + return
{children}
; + } + // Determine the current tab based on the pathname const getCurrentTab = (): SidebarTab | null => { if (pathname === '/projects' || pathname?.startsWith('/projects/')) { diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts new file mode 100644 index 0000000..87af032 --- /dev/null +++ b/frontend/src/middleware.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // Allow access to /create and its assets + if (pathname === '/create' || pathname.startsWith('/_next') || pathname.startsWith('/api')) { + return NextResponse.next(); + } + + // Allow static files (images, fonts, etc.) + if ( + pathname.startsWith('/favicon.ico') || + pathname.startsWith('/logo.png') || + pathname.match(/\.(jpg|jpeg|png|gif|svg|webp|ico|woff|woff2|ttf)$/) + ) { + return NextResponse.next(); + } + + // Redirect all other routes to /create + const url = request.nextUrl.clone(); + url.pathname = '/create'; + return NextResponse.redirect(url); +} + +// Configure which routes the middleware runs on +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + */ + '/((?!_next/static|_next/image|favicon.ico).*)', + ], +};