From b015e95bac0e00ab818b3b4a878236b1552d7aba Mon Sep 17 00:00:00 2001 From: SuveenE Date: Sat, 18 Oct 2025 20:07:53 +0800 Subject: [PATCH 01/12] Ui updates --- frontend/src/app/projects/page.tsx | 2 +- frontend/src/app/settings/page.tsx | 2 +- frontend/src/components/canvas/image-sidebar.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/projects/page.tsx b/frontend/src/app/projects/page.tsx index 902d402..39790ca 100644 --- a/frontend/src/app/projects/page.tsx +++ b/frontend/src/app/projects/page.tsx @@ -10,7 +10,7 @@ export default function ProjectsPage() { return (
-

Projects

+

Projects

{isLoading &&

Loading projects...

} diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index a258e06..dd917b5 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -3,7 +3,7 @@ export default function SettingsPage() { return (
-

Settings

+

Settings

Application settings will appear here.

); diff --git a/frontend/src/components/canvas/image-sidebar.tsx b/frontend/src/components/canvas/image-sidebar.tsx index 417936f..a61936e 100644 --- a/frontend/src/components/canvas/image-sidebar.tsx +++ b/frontend/src/components/canvas/image-sidebar.tsx @@ -131,7 +131,7 @@ export function ImageSidebar({ {/* Live Transcript Display */}
-
+
{transcript || ( Click the microphone icon above to begin... )} From 0a2bc7b4700bde845a1ee4e96bb380cbc533694f Mon Sep 17 00:00:00 2001 From: SuveenE Date: Sat, 18 Oct 2025 20:14:48 +0800 Subject: [PATCH 02/12] Add 20 second image-gen call --- frontend/src/app/page.tsx | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index b148b46..b7bb5bd 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -45,6 +45,7 @@ export default function Home() { const recognitionRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any const editorRef = useRef(null); + const autoGenerateTimerRef = useRef(null); useEffect(() => { // Check if browser supports Speech Recognition @@ -92,6 +93,39 @@ export default function Home() { }; }, []); + // Auto-generate in Agent Mode after 20 seconds of listening + useEffect(() => { + // Clear any existing timer + if (autoGenerateTimerRef.current) { + clearTimeout(autoGenerateTimerRef.current); + autoGenerateTimerRef.current = null; + } + + // Only set timer if listening has started + if (isListening && editorRef.current && frameId) { + // Check if canvas is empty (Agent Mode) + const editor = editorRef.current; + const childShapeIds = editor.getSortedChildIdsForParent(frameId); + const isCanvasEmpty = childShapeIds.length === 0; + + if (isCanvasEmpty) { + console.log('Agent Mode detected: Auto-generate will trigger in 20 seconds'); + autoGenerateTimerRef.current = setTimeout(() => { + console.log('Auto-generating image after 20 seconds in Agent Mode'); + handleGenerate(); + }, 20000); // 20 seconds + } + } + + // Cleanup on unmount or when listening stops + return () => { + if (autoGenerateTimerRef.current) { + clearTimeout(autoGenerateTimerRef.current); + autoGenerateTimerRef.current = null; + } + }; + }, [isListening, frameId]); + const toggleListening = () => { if (!recognitionRef.current) { alert('Speech recognition is not supported in your browser'); From 690b97534a242219d2bc4eea2ad80ad5041d858e Mon Sep 17 00:00:00 2001 From: SuveenE Date: Sat, 18 Oct 2025 20:20:25 +0800 Subject: [PATCH 03/12] Automatic image-gen called fixed --- frontend/src/app/page.tsx | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index b7bb5bd..95e998a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -46,6 +46,13 @@ export default function Home() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const editorRef = useRef(null); const autoGenerateTimerRef = useRef(null); + const transcriptRef = useRef(''); + const handleGenerateRef = useRef<(() => Promise) | null>(null); + + // Keep transcript ref in sync with transcript state + useEffect(() => { + transcriptRef.current = transcript; + }, [transcript]); useEffect(() => { // Check if browser supports Speech Recognition @@ -111,8 +118,17 @@ export default function Home() { if (isCanvasEmpty) { console.log('Agent Mode detected: Auto-generate will trigger in 20 seconds'); autoGenerateTimerRef.current = setTimeout(() => { - console.log('Auto-generating image after 20 seconds in Agent Mode'); - handleGenerate(); + // Only auto-generate if we have a transcript (use ref to get latest value) + const currentTranscript = transcriptRef.current.trim(); + if (currentTranscript && handleGenerateRef.current) { + console.log( + 'Auto-generating image after 20 seconds in Agent Mode with transcript:', + currentTranscript, + ); + handleGenerateRef.current(); + } else { + console.log('No transcript available yet, skipping auto-generate'); + } }, 20000); // 20 seconds } } @@ -460,7 +476,7 @@ export default function Home() { return () => window.removeEventListener('keydown', handleKeyDown); }, [imageUsed, generatedImage, showSlider, handleAcceptImage, handleRejectImage]); - const exportCanvasImage = async (): Promise => { + const exportCanvasImage = useCallback(async (): Promise => { if (!editorRef.current || !frameId) return null; const editor = editorRef.current; @@ -497,9 +513,9 @@ export default function Home() { reader.onerror = reject; reader.readAsDataURL(blob); }); - }; + }, [frameId]); - const handleGenerate = async () => { + const handleGenerate = useCallback(async () => { if (!transcript.trim()) { setError('Please provide a voice prompt first'); return; @@ -533,7 +549,12 @@ export default function Home() { } finally { setIsGenerating(false); } - }; + }, [transcript, exportCanvasImage, projectId]); + + // Keep handleGenerate ref in sync + useEffect(() => { + handleGenerateRef.current = handleGenerate; + }, [handleGenerate]); return (
From 58936b1f6debb446f75166648769608c365456dd Mon Sep 17 00:00:00 2001 From: SuveenE Date: Sat, 18 Oct 2025 20:25:50 +0800 Subject: [PATCH 04/12] Fix useState bug --- frontend/src/app/page.tsx | 57 ++++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 95e998a..0ae7318 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -21,7 +21,8 @@ export default function Home() { const [generatedImage, setGeneratedImage] = useState(null); const [isGenerating, setIsGenerating] = useState(false); const [isListening, setIsListening] = useState(false); - const [transcript, setTranscript] = useState(''); + const [agentTranscript, setAgentTranscript] = useState(''); // Transcript for Agent Mode + const [askTranscript, setAskTranscript] = useState(''); // Transcript for Ask Mode const [error, setError] = useState(null); const [frameId, setFrameId] = useState(null); const [imageUsed, setImageUsed] = useState(false); @@ -49,10 +50,21 @@ export default function Home() { const transcriptRef = useRef(''); const handleGenerateRef = useRef<(() => Promise) | null>(null); - // Keep transcript ref in sync with transcript state + // Helper to determine current mode (Agent Mode = empty canvas, Ask Mode = has content) + const isAgentMode = useCallback((): boolean => { + if (!editorRef.current || !frameId) return true; // Default to Agent Mode if not ready + const editor = editorRef.current; + const childShapeIds = editor.getSortedChildIdsForParent(frameId); + return childShapeIds.length === 0; + }, [frameId]); + + // Get the appropriate transcript based on current mode + const currentTranscript = isAgentMode() ? agentTranscript : askTranscript; + + // Keep transcript ref in sync with current transcript useEffect(() => { - transcriptRef.current = transcript; - }, [transcript]); + transcriptRef.current = currentTranscript; + }, [currentTranscript]); useEffect(() => { // Check if browser supports Speech Recognition @@ -78,7 +90,12 @@ export default function Home() { } } - setTranscript((prev) => prev + finalTranscript); + // Update the appropriate transcript based on current mode + if (isAgentMode()) { + setAgentTranscript((prev) => prev + finalTranscript); + } else { + setAskTranscript((prev) => prev + finalTranscript); + } }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -98,7 +115,7 @@ export default function Home() { recognitionRef.current.stop(); } }; - }, []); + }, [isAgentMode]); // Auto-generate in Agent Mode after 20 seconds of listening useEffect(() => { @@ -140,7 +157,7 @@ export default function Home() { autoGenerateTimerRef.current = null; } }; - }, [isListening, frameId]); + }, [isListening, frameId, isAgentMode]); const toggleListening = () => { if (!recognitionRef.current) { @@ -152,7 +169,12 @@ export default function Home() { recognitionRef.current.stop(); setIsListening(false); } else { - setTranscript(''); + // Clear the appropriate transcript based on current mode + if (isAgentMode()) { + setAgentTranscript(''); + } else { + setAskTranscript(''); + } recognitionRef.current.start(); setIsListening(true); } @@ -516,7 +538,9 @@ export default function Home() { }, [frameId]); const handleGenerate = useCallback(async () => { - if (!transcript.trim()) { + const activeTranscript = isAgentMode() ? agentTranscript : askTranscript; + + if (!activeTranscript.trim()) { setError('Please provide a voice prompt first'); return; } @@ -531,7 +555,7 @@ export default function Home() { const requestType = canvasImageData ? 'edit' : 'generate'; const data = await generateImage({ - prompt: transcript, + prompt: activeTranscript, image_data: canvasImageData, project_id: projectId, type: requestType, @@ -549,7 +573,7 @@ export default function Home() { } finally { setIsGenerating(false); } - }, [transcript, exportCanvasImage, projectId]); + }, [agentTranscript, askTranscript, exportCanvasImage, projectId, isAgentMode]); // Keep handleGenerate ref in sync useEffect(() => { @@ -645,11 +669,18 @@ export default function Home() { { + // Update the appropriate transcript based on current mode + if (isAgentMode()) { + setAgentTranscript(value); + } else { + setAskTranscript(value); + } + }} onToggleListening={toggleListening} onGenerate={handleGenerate} onAcceptImage={handleAcceptImage} From 98f2c654b2ac33d36fcc4aa80ea0c07b6546818b Mon Sep 17 00:00:00 2001 From: SuveenE Date: Sat, 18 Oct 2025 20:50:13 +0800 Subject: [PATCH 05/12] Add create and update project endpoints --- backend/app/controllers/project.py | 88 +++++++++++++++++++++++- backend/app/models/project.py | 21 ++++++ backend/app/services/project.py | 106 ++++++++++++++++++++++++++++- 3 files changed, 212 insertions(+), 3 deletions(-) diff --git a/backend/app/controllers/project.py b/backend/app/controllers/project.py index dcdd818..12590b2 100644 --- a/backend/app/controllers/project.py +++ b/backend/app/controllers/project.py @@ -2,7 +2,12 @@ from fastapi import APIRouter, Header, HTTPException -from app.models.project import ProjectListResponse +from app.models.project import ( + Project, + ProjectCreateRequest, + ProjectListResponse, + ProjectUpdateRequest, +) from app.services.project import ProjectService from app.utils.database import db_client @@ -53,3 +58,84 @@ async def get_projects( raise HTTPException( status_code=500, detail="An unexpected error occurred" ) + + @router.post( + "", + response_model=Project, + status_code=201, + ) + async def create_project( + project_data: ProjectCreateRequest, + authorization: str = Header(None), + ) -> Project: + """ + Create a new project. + """ + log.info(f"Creating project for user_id: {project_data.user_id}") + try: + # Extract token from authorization header + token = authorization.replace("Bearer ", "") if authorization else "" + + # Get database client + supabase_client = await db_client(token=token) + + # Create project + project = await self.service.create_project( + supabase_client=supabase_client, project_data=project_data + ) + + log.info(f"Successfully created project with id: {project.id}") + return project + + except RuntimeError as e: + log.error(f"Service error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + except Exception as e: + log.error(f"Unexpected error: {e}") + raise HTTPException( + status_code=500, detail="An unexpected error occurred" + ) + + @router.put( + "/{project_id}", + response_model=Project, + ) + async def update_project( + project_id: str, + project_data: ProjectUpdateRequest, + user_id: str, + authorization: str = Header(None), + ) -> Project: + """ + Update an existing project. + """ + log.info(f"Updating project {project_id} for user_id: {user_id}") + try: + # Extract token from authorization header + token = authorization.replace("Bearer ", "") if authorization else "" + + # Get database client + supabase_client = await db_client(token=token) + + # Update project + project = await self.service.update_project( + supabase_client=supabase_client, + project_id=project_id, + user_id=user_id, + project_data=project_data, + ) + + log.info(f"Successfully updated project with id: {project.id}") + return project + + except RuntimeError as e: + log.error(f"Service error: {e}") + # Check if it's an authorization error + if "unauthorized" in str(e).lower() or "not found" in str(e).lower(): + raise HTTPException(status_code=404, detail=str(e)) + raise HTTPException(status_code=500, detail=str(e)) + except Exception as e: + log.error(f"Unexpected error: {e}") + raise HTTPException( + status_code=500, detail="An unexpected error occurred" + ) diff --git a/backend/app/models/project.py b/backend/app/models/project.py index 2ddabab..098f102 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -24,3 +24,24 @@ class Project(BaseModel): class ProjectListResponse(BaseModel): projects: List[Project] = Field(description="List of projects for the user.") + + +class ProjectCreateRequest(BaseModel): + user_id: str = Field(description="The user ID who owns the project.") + name: str = Field(description="The name of the project.") + description: Optional[str] = Field( + default=None, description="The description of the project." + ) + snapshot: Optional[Dict[str, Any]] = Field( + default=None, description="The snapshot data for the project." + ) + + +class ProjectUpdateRequest(BaseModel): + name: Optional[str] = Field(default=None, description="The name of the project.") + description: Optional[str] = Field( + default=None, description="The description of the project." + ) + snapshot: Optional[Dict[str, Any]] = Field( + default=None, description="The snapshot data for the project." + ) diff --git a/backend/app/services/project.py b/backend/app/services/project.py index 9739922..bfbca9f 100644 --- a/backend/app/services/project.py +++ b/backend/app/services/project.py @@ -1,9 +1,10 @@ import logging -from typing import List +from typing import Dict, List +from uuid import uuid4 from supabase._async.client import AsyncClient as Client -from app.models.project import Project +from app.models.project import Project, ProjectCreateRequest, ProjectUpdateRequest log = logging.getLogger(__name__) @@ -47,3 +48,104 @@ async def get_projects_by_user_id( except Exception as e: log.error(f"Error fetching projects for user_id {user_id}: {e}") raise RuntimeError(f"Failed to fetch projects: {e}") + + async def create_project( + self, supabase_client: Client, project_data: ProjectCreateRequest + ) -> Project: + """ + Create a new project. + + Args: + supabase_client: The Supabase client instance + project_data: The project data to create + + Returns: + Created Project object + """ + log.info(f"Creating project for user_id: {project_data.user_id}") + + try: + # Prepare project data with generated ID + new_project = { + "id": str(uuid4()), + "user_id": project_data.user_id, + "name": project_data.name, + "description": project_data.description, + "snapshot": project_data.snapshot, + } + + # Insert into projects table + response = ( + await supabase_client.table("projects") + .insert(new_project) + .execute() + ) + + if not response.data or len(response.data) == 0: + raise RuntimeError("Failed to create project: No data returned") + + project = Project(**response.data[0]) + log.info(f"Successfully created project with id: {project.id}") + + return project + + except Exception as e: + log.error(f"Error creating project: {e}") + raise RuntimeError(f"Failed to create project: {e}") + + async def update_project( + self, + supabase_client: Client, + project_id: str, + user_id: str, + project_data: ProjectUpdateRequest, + ) -> Project: + """ + Update an existing project. + + Args: + supabase_client: The Supabase client instance + project_id: The ID of the project to update + user_id: The user ID who owns the project (for authorization) + project_data: The project data to update + + Returns: + Updated Project object + """ + log.info(f"Updating project {project_id} for user_id: {user_id}") + + try: + # Prepare update data (only include non-None fields) + update_data: Dict = {} + if project_data.name is not None: + update_data["name"] = project_data.name + if project_data.description is not None: + update_data["description"] = project_data.description + if project_data.snapshot is not None: + update_data["snapshot"] = project_data.snapshot + + if not update_data: + raise RuntimeError("No fields to update") + + # Update the project (with user_id check for authorization) + response = ( + await supabase_client.table("projects") + .update(update_data) + .eq("id", project_id) + .eq("user_id", user_id) + .execute() + ) + + if not response.data or len(response.data) == 0: + raise RuntimeError( + "Failed to update project: Project not found or unauthorized" + ) + + project = Project(**response.data[0]) + log.info(f"Successfully updated project with id: {project.id}") + + return project + + except Exception as e: + log.error(f"Error updating project {project_id}: {e}") + raise RuntimeError(f"Failed to update project: {e}") From 4a2d5b29095bd7a554c94f5941930346636fd927 Mon Sep 17 00:00:00 2001 From: SuveenE Date: Sat, 18 Oct 2025 21:05:43 +0800 Subject: [PATCH 06/12] Refactor --- frontend/src/actions/projects.ts | 53 ++ frontend/src/app/page.tsx | 698 +---------------- .../projects/[project_id]/analysis/page.tsx | 80 ++ .../src/app/projects/[project_id]/page.tsx | 737 ++++++++++++++++-- frontend/src/components/shared/sidebar.tsx | 69 +- 5 files changed, 881 insertions(+), 756 deletions(-) create mode 100644 frontend/src/app/projects/[project_id]/analysis/page.tsx diff --git a/frontend/src/actions/projects.ts b/frontend/src/actions/projects.ts index 0d82607..8dad3ef 100644 --- a/frontend/src/actions/projects.ts +++ b/frontend/src/actions/projects.ts @@ -15,6 +15,19 @@ export interface ProjectsResponse { projects: Project[]; } +export interface CreateProjectRequest { + user_id: string; + name: string; + description?: string; + snapshot?: Record; +} + +export interface UpdateProjectRequest { + name?: string; + description?: string; + snapshot?: Record; +} + export async function fetchProjects(userId: string): Promise { const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; const response = await fetch(`${apiUrl}/api/projects/${userId}`, { @@ -31,3 +44,43 @@ export async function fetchProjects(userId: string): Promise { return response.json(); } + +export async function createProject(projectData: CreateProjectRequest): Promise { + const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; + const response = await fetch(`${apiUrl}/api/projects`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(projectData), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || 'Failed to create project'); + } + + return response.json(); +} + +export async function updateProject( + projectId: string, + userId: string, + projectData: UpdateProjectRequest, +): Promise { + const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; + const response = await fetch(`${apiUrl}/api/projects/${projectId}?user_id=${userId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(projectData), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || 'Failed to update project'); + } + + return response.json(); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 0ae7318..6ee316b 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,703 +1,19 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; -import { generateImage } from '@/actions/image'; -import { Tldraw, createShapeId } from 'tldraw'; -import 'tldraw/tldraw.css'; - -import { BeforeAfterSlider } from '@/components/canvas/before-after-slider'; -import { ImageSidebar } from '@/components/canvas/image-sidebar'; - -// 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; -} +import { useEffect } from 'react'; export default function Home() { - const [generatedImage, setGeneratedImage] = useState(null); - const [isGenerating, setIsGenerating] = useState(false); - const [isListening, setIsListening] = useState(false); - const [agentTranscript, setAgentTranscript] = useState(''); // Transcript for Agent Mode - const [askTranscript, setAskTranscript] = useState(''); // Transcript 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); - const [projectName, setProjectName] = useState('Untitled'); - const [isEditingName, setIsEditingName] = useState(false); - const [projectId] = useState(() => { - // Generate a UUID for the project session - return '6e8dec9f-4d2c-4b7e-9053-28e5ebe4aeb9'; - //return crypto.randomUUID(); - }); - // 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 transcriptRef = useRef(''); - const handleGenerateRef = useRef<(() => Promise) | null>(null); - - // Helper to determine current mode (Agent Mode = empty canvas, Ask Mode = has content) - const isAgentMode = useCallback((): boolean => { - if (!editorRef.current || !frameId) return true; // Default to Agent Mode if not ready - const editor = editorRef.current; - const childShapeIds = editor.getSortedChildIdsForParent(frameId); - return childShapeIds.length === 0; - }, [frameId]); - - // Get the appropriate transcript based on current mode - const currentTranscript = isAgentMode() ? agentTranscript : askTranscript; - - // Keep transcript ref in sync with current transcript - useEffect(() => { - transcriptRef.current = currentTranscript; - }, [currentTranscript]); + const router = useRouter(); 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 + ' '; - } - } - - // Update the appropriate transcript based on current mode - if (isAgentMode()) { - setAgentTranscript((prev) => prev + finalTranscript); - } else { - setAskTranscript((prev) => prev + finalTranscript); - } - }; - - // 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 = () => { - setIsListening(false); - }; - } - } - - return () => { - if (recognitionRef.current) { - recognitionRef.current.stop(); - } - }; - }, [isAgentMode]); - - // Auto-generate in Agent Mode after 20 seconds of listening - useEffect(() => { - // Clear any existing timer - if (autoGenerateTimerRef.current) { - clearTimeout(autoGenerateTimerRef.current); - autoGenerateTimerRef.current = null; - } - - // Only set timer if listening has started - if (isListening && editorRef.current && frameId) { - // Check if canvas is empty (Agent Mode) - const editor = editorRef.current; - const childShapeIds = editor.getSortedChildIdsForParent(frameId); - const isCanvasEmpty = childShapeIds.length === 0; - - if (isCanvasEmpty) { - console.log('Agent Mode detected: Auto-generate will trigger in 20 seconds'); - autoGenerateTimerRef.current = setTimeout(() => { - // Only auto-generate if we have a transcript (use ref to get latest value) - const currentTranscript = transcriptRef.current.trim(); - if (currentTranscript && handleGenerateRef.current) { - console.log( - 'Auto-generating image after 20 seconds in Agent Mode with transcript:', - currentTranscript, - ); - handleGenerateRef.current(); - } else { - console.log('No transcript available yet, skipping auto-generate'); - } - }, 20000); // 20 seconds - } - } - - // Cleanup on unmount or when listening stops - return () => { - if (autoGenerateTimerRef.current) { - clearTimeout(autoGenerateTimerRef.current); - autoGenerateTimerRef.current = null; - } - }; - }, [isListening, frameId, isAgentMode]); - - const toggleListening = () => { - if (!recognitionRef.current) { - alert('Speech recognition is not supported in your browser'); - return; - } - - if (isListening) { - recognitionRef.current.stop(); - setIsListening(false); - } else { - // Clear the appropriate transcript based on current mode - if (isAgentMode()) { - setAgentTranscript(''); - } else { - setAskTranscript(''); - } - 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 => { - if (!editorRef.current || !frameId) return null; - - const editor = editorRef.current; - const frame = editor.getShape(frameId); - - if (!frame) return null; - - // 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; - }); - - // Include the frame itself and all its children for export - const shapeIdsToExport = [frameId, ...childShapeIds]; - - if (shapeIdsToExport.length === 0) return null; - - // Export only the frame and its contents to PNG - const { blob } = await editor.toImage(shapeIdsToExport, { - format: 'png', - background: true, - padding: 0, - }); - - // 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 - resolve(base64data.split(',')[1]); - }; - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - }, [frameId]); - - const handleGenerate = useCallback(async () => { - const activeTranscript = isAgentMode() ? agentTranscript : askTranscript; - - if (!activeTranscript.trim()) { - setError('Please provide a voice prompt first'); - return; - } - - setIsGenerating(true); - setError(null); - - try { - const canvasImageData = await exportCanvasImage(); - - // Determine type: "generate" for agent mode (no canvas content), "edit" for ask mode (has canvas content) - const requestType = canvasImageData ? 'edit' : 'generate'; - - const data = await generateImage({ - prompt: activeTranscript, - image_data: canvasImageData, - project_id: projectId, - type: requestType, - }); - - 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); - } - }, [agentTranscript, askTranscript, exportCanvasImage, projectId, isAgentMode]); - - // Keep handleGenerate ref in sync - useEffect(() => { - handleGenerateRef.current = handleGenerate; - }, [handleGenerate]); + router.push('/projects'); + }, [router]); return ( -
- {/* Left Side - Drawing Canvas */} -
-
- Projects/ - {isEditingName ? ( - setProjectName(e.target.value)} - onBlur={() => setIsEditingName(false)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - setIsEditingName(false); - } - }} - autoFocus - className="inline-block w-auto min-w-[60px] border-b border-blue-500 bg-transparent px-1 outline-none" - style={{ width: `${Math.max(60, projectName.length * 8 + 10)}px` }} - /> - ) : ( - setIsEditingName(true)} - className="cursor-pointer rounded px-1 hover:bg-gray-100" - > - {projectName} - - )} -
-
- { - 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) { - setFrameId(existingFrame); - editor.zoomToFit(); - return; - } - - // Create a centered frame for drawing - 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 */} - { - // Update the appropriate transcript based on current mode - if (isAgentMode()) { - setAgentTranscript(value); - } else { - setAskTranscript(value); - } - }} - onToggleListening={toggleListening} - onGenerate={handleGenerate} - onAcceptImage={handleAcceptImage} - onRejectImage={handleRejectImage} - canvasReady={!!(frameId && editorRef.current)} - /> - - {/* Before/After Slider Overlay */} - {showSlider && beforeImage && ( - setShowSlider(false)} - frameBounds={frameBounds || undefined} - /> - )} +
+

Redirecting to projects...

); } diff --git a/frontend/src/app/projects/[project_id]/analysis/page.tsx b/frontend/src/app/projects/[project_id]/analysis/page.tsx new file mode 100644 index 0000000..f95aca6 --- /dev/null +++ b/frontend/src/app/projects/[project_id]/analysis/page.tsx @@ -0,0 +1,80 @@ +'use client'; + +import Link from 'next/link'; +import { useParams } from 'next/navigation'; + +import { DEFAULT_USER_ID } from '@/actions/projects'; +import { useImagePairs } from '@/hooks/useImagePairs'; +import { useProject } from '@/hooks/useProject'; + +import { ImagePairCard } from '@/components/projects/image-pair-card'; + +export default function ProjectDetailPage() { + const params = useParams(); + const projectId = params.project_id as string; + + const { + data: project, + isLoading: isLoadingProject, + error: projectError, + } = useProject(projectId, DEFAULT_USER_ID); + + const { + data: imagePairsData, + isLoading: isLoadingImagePairs, + error: imagePairsError, + } = useImagePairs(projectId); + + const isLoading = isLoadingProject || isLoadingImagePairs; + const error = projectError || imagePairsError; + + return ( +
+ {/* Breadcrumb */} + + + {/* Project Header */} + {project && ( +
+

{project.name}

+ {project.description &&

{project.description}

} +
+

Created: {new Date(project.created_at).toLocaleDateString()}

+

Last Updated: {new Date(project.updated_at).toLocaleDateString()}

+
+
+ )} + + {/* Loading State */} + {isLoading &&

Loading...

} + + {/* Error State */} + {error &&

Error: {error.message}

} + + {/* Image Pairs Section */} + {imagePairsData && ( +
+

+ Image Pairs ({imagePairsData.image_pairs.length}) +

+ + {imagePairsData.image_pairs.length === 0 ? ( +

No image pairs found for this project.

+ ) : ( +
+ {imagePairsData.image_pairs.map((imagePair) => ( + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/app/projects/[project_id]/page.tsx b/frontend/src/app/projects/[project_id]/page.tsx index f95aca6..1d3baf3 100644 --- a/frontend/src/app/projects/[project_id]/page.tsx +++ b/frontend/src/app/projects/[project_id]/page.tsx @@ -1,79 +1,702 @@ 'use client'; -import Link from 'next/link'; import { useParams } from 'next/navigation'; -import { DEFAULT_USER_ID } from '@/actions/projects'; -import { useImagePairs } from '@/hooks/useImagePairs'; -import { useProject } from '@/hooks/useProject'; +import { useCallback, useEffect, useRef, useState } from 'react'; -import { ImagePairCard } from '@/components/projects/image-pair-card'; +import { generateImage } from '@/actions/image'; +import { Tldraw, createShapeId } from 'tldraw'; +import 'tldraw/tldraw.css'; -export default function ProjectDetailPage() { +import { BeforeAfterSlider } from '@/components/canvas/before-after-slider'; +import { ImageSidebar } from '@/components/canvas/image-sidebar'; + +// 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 ProjectCanvasPage() { const params = useParams(); const projectId = params.project_id as string; - const { - data: project, - isLoading: isLoadingProject, - error: projectError, - } = useProject(projectId, DEFAULT_USER_ID); + const [generatedImage, setGeneratedImage] = useState(null); + const [isGenerating, setIsGenerating] = useState(false); + const [isListening, setIsListening] = useState(false); + const [agentTranscript, setAgentTranscript] = useState(''); // Transcript for Agent Mode + const [askTranscript, setAskTranscript] = useState(''); // Transcript 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); + const [projectName, setProjectName] = useState('Untitled'); + const [isEditingName, setIsEditingName] = useState(false); + // 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 transcriptRef = useRef(''); + const handleGenerateRef = useRef<(() => Promise) | null>(null); - const { - data: imagePairsData, - isLoading: isLoadingImagePairs, - error: imagePairsError, - } = useImagePairs(projectId); + // Helper to determine current mode (Agent Mode = empty canvas, Ask Mode = has content) + const isAgentMode = useCallback((): boolean => { + if (!editorRef.current || !frameId) return true; // Default to Agent Mode if not ready + const editor = editorRef.current; + const childShapeIds = editor.getSortedChildIdsForParent(frameId); + return childShapeIds.length === 0; + }, [frameId]); - const isLoading = isLoadingProject || isLoadingImagePairs; - const error = projectError || imagePairsError; + // Get the appropriate transcript based on current mode + const currentTranscript = isAgentMode() ? agentTranscript : askTranscript; - return ( -
- {/* Breadcrumb */} - - - {/* Project Header */} - {project && ( -
-

{project.name}

- {project.description &&

{project.description}

} -
-

Created: {new Date(project.created_at).toLocaleDateString()}

-

Last Updated: {new Date(project.updated_at).toLocaleDateString()}

-
-
- )} + // Keep transcript ref in sync with current transcript + useEffect(() => { + transcriptRef.current = currentTranscript; + }, [currentTranscript]); + + 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 + ' '; + } + } + + // Update the appropriate transcript based on current mode + if (isAgentMode()) { + setAgentTranscript((prev) => prev + finalTranscript); + } else { + setAskTranscript((prev) => prev + finalTranscript); + } + }; + + // 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 = () => { + setIsListening(false); + }; + } + } + + return () => { + if (recognitionRef.current) { + recognitionRef.current.stop(); + } + }; + }, [isAgentMode]); + + // Auto-generate in Agent Mode after 20 seconds of listening + useEffect(() => { + // Clear any existing timer + if (autoGenerateTimerRef.current) { + clearTimeout(autoGenerateTimerRef.current); + autoGenerateTimerRef.current = null; + } + + // Only set timer if listening has started + if (isListening && editorRef.current && frameId) { + // Check if canvas is empty (Agent Mode) + const editor = editorRef.current; + const childShapeIds = editor.getSortedChildIdsForParent(frameId); + const isCanvasEmpty = childShapeIds.length === 0; + + if (isCanvasEmpty) { + console.log('Agent Mode detected: Auto-generate will trigger in 20 seconds'); + autoGenerateTimerRef.current = setTimeout(() => { + // Only auto-generate if we have a transcript (use ref to get latest value) + const currentTranscript = transcriptRef.current.trim(); + if (currentTranscript && handleGenerateRef.current) { + console.log( + 'Auto-generating image after 20 seconds in Agent Mode with transcript:', + currentTranscript, + ); + handleGenerateRef.current(); + } else { + console.log('No transcript available yet, skipping auto-generate'); + } + }, 20000); // 20 seconds + } + } + + // Cleanup on unmount or when listening stops + return () => { + if (autoGenerateTimerRef.current) { + clearTimeout(autoGenerateTimerRef.current); + autoGenerateTimerRef.current = null; + } + }; + }, [isListening, frameId, isAgentMode]); + + const toggleListening = () => { + if (!recognitionRef.current) { + alert('Speech recognition is not supported in your browser'); + return; + } + + if (isListening) { + recognitionRef.current.stop(); + setIsListening(false); + } else { + // Clear the appropriate transcript based on current mode + if (isAgentMode()) { + setAgentTranscript(''); + } else { + setAskTranscript(''); + } + 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; + } - {/* Loading State */} - {isLoading &&

Loading...

} + try { + // If no generated image, use test.png for testing + let imageToUse = generatedImage; + if (!imageToUse) { + const testImageBase64 = await loadTestImage(); + setGeneratedImage(testImageBase64); + imageToUse = testImageBase64; + } - {/* Error State */} - {error &&

Error: {error.message}

} + // Capture before state first + const before = await captureCanvasSnapshot(); + if (!before) { + setError('Failed to capture canvas state'); + return; + } - {/* Image Pairs Section */} - {imagePairsData && ( -
-

- Image Pairs ({imagePairsData.image_pairs.length}) -

+ // Get frame bounds for positioning the slider + const bounds = getFrameBounds(); + setFrameBounds(bounds); - {imagePairsData.image_pairs.length === 0 ? ( -

No image pairs found for this project.

+ // 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 => { + if (!editorRef.current || !frameId) return null; + + const editor = editorRef.current; + const frame = editor.getShape(frameId); + + if (!frame) return null; + + // 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; + }); + + // Include the frame itself and all its children for export + const shapeIdsToExport = [frameId, ...childShapeIds]; + + if (shapeIdsToExport.length === 0) return null; + + // Export only the frame and its contents to PNG + const { blob } = await editor.toImage(shapeIdsToExport, { + format: 'png', + background: true, + padding: 0, + }); + + // 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 + resolve(base64data.split(',')[1]); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + }, [frameId]); + + const handleGenerate = useCallback(async () => { + const activeTranscript = isAgentMode() ? agentTranscript : askTranscript; + + if (!activeTranscript.trim()) { + setError('Please provide a voice prompt first'); + return; + } + + setIsGenerating(true); + setError(null); + + try { + const canvasImageData = await exportCanvasImage(); + + // Determine type: "generate" for agent mode (no canvas content), "edit" for ask mode (has canvas content) + const requestType = canvasImageData ? 'edit' : 'generate'; + + const data = await generateImage({ + prompt: activeTranscript, + image_data: canvasImageData, + project_id: projectId, + type: requestType, + }); + + 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); + } + }, [agentTranscript, askTranscript, exportCanvasImage, projectId, isAgentMode]); + + // Keep handleGenerate ref in sync + useEffect(() => { + handleGenerateRef.current = handleGenerate; + }, [handleGenerate]); + + return ( +
+ {/* Left Side - Drawing Canvas */} +
+
+ Projects/ + {isEditingName ? ( + setProjectName(e.target.value)} + onBlur={() => setIsEditingName(false)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setIsEditingName(false); + } + }} + autoFocus + className="inline-block w-auto min-w-[60px] border-b border-blue-500 bg-transparent px-1 outline-none" + style={{ width: `${Math.max(60, projectName.length * 8 + 10)}px` }} + /> ) : ( -
- {imagePairsData.image_pairs.map((imagePair) => ( - - ))} -
+ setIsEditingName(true)} + className="cursor-pointer rounded px-1 hover:bg-gray-100" + > + {projectName} + )}
+
+ { + 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) { + setFrameId(existingFrame); + editor.zoomToFit(); + return; + } + + // Create a centered frame for drawing + 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 */} + { + // Update the appropriate transcript based on current mode + if (isAgentMode()) { + setAgentTranscript(value); + } else { + setAskTranscript(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/components/shared/sidebar.tsx b/frontend/src/components/shared/sidebar.tsx index 2e9edc5..0ab4801 100644 --- a/frontend/src/components/shared/sidebar.tsx +++ b/frontend/src/components/shared/sidebar.tsx @@ -3,15 +3,17 @@ import Image from 'next/image'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; +import { useRouter } from 'next/navigation'; import React, { useState } from 'react'; +import { DEFAULT_USER_ID, createProject } from '@/actions/projects'; import { FolderOpen, Menu, PenTool, Settings, Sidebar, X } from 'lucide-react'; +import { toast } from 'sonner'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; enum SidebarTab { - CREATE = 'Create', PROJECTS = 'Projects', SETTINGS = 'Settings', } @@ -23,7 +25,6 @@ interface SidebarTabInfo { } const sidebarTabs: SidebarTabInfo[] = [ - { value: SidebarTab.CREATE, icon: , path: '/' }, { value: SidebarTab.PROJECTS, icon: , @@ -109,20 +110,20 @@ const SidebarItem: React.FC = ({ const SidebarView: React.FC = ({ children }) => { const pathname = usePathname(); + const router = useRouter(); const [isSidebarExpanded, setIsSidebarExpanded] = useState(false); const [hoveredTab, setHoveredTab] = useState(null); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [isCreatingProject, setIsCreatingProject] = useState(false); // Determine the current tab based on the pathname - const getCurrentTab = (): SidebarTab => { - if (pathname === '/') { - return SidebarTab.CREATE; - } else if (pathname === '/projects' || pathname?.startsWith('/projects/')) { + const getCurrentTab = (): SidebarTab | null => { + if (pathname === '/projects' || pathname?.startsWith('/projects/')) { return SidebarTab.PROJECTS; } else if (pathname === '/settings' || pathname?.startsWith('/settings/')) { return SidebarTab.SETTINGS; } - return SidebarTab.CREATE; // Default fallback + return null; // No tab selected for other routes }; const handleSidebarToggle = () => { @@ -133,7 +134,7 @@ const SidebarView: React.FC = ({ children }) => { setIsMobileMenuOpen(!isMobileMenuOpen); }; - const handleTabHover = (tab: SidebarTab, isHovering: boolean) => { + const handleTabHover = (tab: SidebarTab | null, isHovering: boolean) => { setHoveredTab(isHovering ? tab : null); }; @@ -141,6 +142,28 @@ const SidebarView: React.FC = ({ children }) => { setIsMobileMenuOpen(false); }; + const handleCreateProject = async () => { + if (isCreatingProject) return; + + setIsCreatingProject(true); + try { + const newProject = await createProject({ + user_id: DEFAULT_USER_ID, + name: `New Project ${new Date().toLocaleDateString()}`, + description: '', + }); + + toast.success('Project created successfully!'); + router.push(`/projects/${newProject.id}`); + setIsMobileMenuOpen(false); + } catch (error) { + console.error('Failed to create project:', error); + toast.error('Failed to create project. Please try again.'); + } finally { + setIsCreatingProject(false); + } + }; + const selectedTab = getCurrentTab(); return ( @@ -185,6 +208,20 @@ const SidebarView: React.FC = ({ children }) => { {/* Navigation Items */}
+ {/* Create New Project Button */} + + {sidebarTabs.map((tab) => ( = ({ children }) => { {/* Navigation icons */}
+ {/* Create New Project Button (Collapsed) */} + + + + + + {isCreatingProject ? 'Creating...' : 'Create'} + + + {sidebarTabs.map((tab) => ( Date: Sat, 18 Oct 2025 21:22:13 +0800 Subject: [PATCH 07/12] Dialog ui updates --- .../src/app/projects/[project_id]/page.tsx | 31 +----- frontend/src/components/shared/sidebar.tsx | 84 ++++++++++++-- frontend/src/components/ui/dialog.tsx | 105 ++++++++++++++++++ 3 files changed, 183 insertions(+), 37 deletions(-) create mode 100644 frontend/src/components/ui/dialog.tsx diff --git a/frontend/src/app/projects/[project_id]/page.tsx b/frontend/src/app/projects/[project_id]/page.tsx index 1d3baf3..866c16e 100644 --- a/frontend/src/app/projects/[project_id]/page.tsx +++ b/frontend/src/app/projects/[project_id]/page.tsx @@ -5,6 +5,8 @@ import { useParams } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; import { generateImage } from '@/actions/image'; +import { DEFAULT_USER_ID } from '@/actions/projects'; +import { useProject } from '@/hooks/useProject'; import { Tldraw, createShapeId } from 'tldraw'; import 'tldraw/tldraw.css'; @@ -23,6 +25,9 @@ export default function ProjectCanvasPage() { const params = useParams(); const projectId = params.project_id as string; + // Fetch project data + const { data: project } = useProject(projectId, DEFAULT_USER_ID); + const [generatedImage, setGeneratedImage] = useState(null); const [isGenerating, setIsGenerating] = useState(false); const [isListening, setIsListening] = useState(false); @@ -40,8 +45,6 @@ export default function ProjectCanvasPage() { width: number; height: number; } | null>(null); - const [projectName, setProjectName] = useState('Untitled'); - const [isEditingName, setIsEditingName] = useState(false); // eslint-disable-next-line @typescript-eslint/no-explicit-any const recognitionRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -586,29 +589,7 @@ export default function ProjectCanvasPage() {
Projects/ - {isEditingName ? ( - setProjectName(e.target.value)} - onBlur={() => setIsEditingName(false)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - setIsEditingName(false); - } - }} - autoFocus - className="inline-block w-auto min-w-[60px] border-b border-blue-500 bg-transparent px-1 outline-none" - style={{ width: `${Math.max(60, projectName.length * 8 + 10)}px` }} - /> - ) : ( - setIsEditingName(true)} - className="cursor-pointer rounded px-1 hover:bg-gray-100" - > - {projectName} - - )} + {project?.name || 'Loading...'}
= ({ children }) => { const [hoveredTab, setHoveredTab] = useState(null); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isCreatingProject, setIsCreatingProject] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [projectName, setProjectName] = useState(''); // Determine the current tab based on the pathname const getCurrentTab = (): SidebarTab | null => { @@ -142,18 +154,25 @@ const SidebarView: React.FC = ({ children }) => { setIsMobileMenuOpen(false); }; + const handleOpenDialog = () => { + setProjectName(''); + setIsDialogOpen(true); + }; + const handleCreateProject = async () => { - if (isCreatingProject) return; + if (isCreatingProject || !projectName.trim()) return; setIsCreatingProject(true); try { const newProject = await createProject({ user_id: DEFAULT_USER_ID, - name: `New Project ${new Date().toLocaleDateString()}`, + name: projectName.trim(), description: '', }); toast.success('Project created successfully!'); + setIsDialogOpen(false); + setProjectName(''); router.push(`/projects/${newProject.id}`); setIsMobileMenuOpen(false); } catch (error) { @@ -164,6 +183,12 @@ const SidebarView: React.FC = ({ children }) => { } }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && projectName.trim()) { + handleCreateProject(); + } + }; + const selectedTab = getCurrentTab(); return ( @@ -210,15 +235,12 @@ const SidebarView: React.FC = ({ children }) => {
{/* Create New Project Button */} @@ -275,15 +297,14 @@ const SidebarView: React.FC = ({ children }) => { - {isCreatingProject ? 'Creating...' : 'Create'} + Create @@ -347,6 +368,45 @@ const SidebarView: React.FC = ({ children }) => {
{children} + + {/* Create Project Dialog */} + + + + Create New Project + Give your project a name to get started. + +
+ setProjectName(e.target.value)} + onKeyDown={handleKeyDown} + autoFocus + /> +
+ + + + +
+
); }; diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..2c7c2ea --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,105 @@ +'use client'; + +import * as React from 'react'; + +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; From b0d49260af9f3a6d9de72f6b574b6471037836ab Mon Sep 17 00:00:00 2001 From: SuveenE Date: Sat, 18 Oct 2025 21:29:46 +0800 Subject: [PATCH 08/12] Update sidebar UI --- frontend/src/components/shared/sidebar.tsx | 14 +++++++------- frontend/src/components/ui/tooltip.tsx | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/shared/sidebar.tsx b/frontend/src/components/shared/sidebar.tsx index 6a881ad..7562529 100644 --- a/frontend/src/components/shared/sidebar.tsx +++ b/frontend/src/components/shared/sidebar.tsx @@ -8,7 +8,7 @@ import { useRouter } from 'next/navigation'; import React, { useState } from 'react'; import { DEFAULT_USER_ID, createProject } from '@/actions/projects'; -import { FolderOpen, Menu, PenTool, Settings, Sidebar, X } from 'lucide-react'; +import { FolderOpen, Menu, Plus, Settings, Sidebar, X } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; @@ -236,11 +236,11 @@ const SidebarView: React.FC = ({ children }) => { {/* Create New Project Button */} @@ -298,13 +298,13 @@ const SidebarView: React.FC = ({ children }) => { - Create + New Project diff --git a/frontend/src/components/ui/tooltip.tsx b/frontend/src/components/ui/tooltip.tsx index c5fc7b3..59210e2 100644 --- a/frontend/src/components/ui/tooltip.tsx +++ b/frontend/src/components/ui/tooltip.tsx @@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - 'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md', + 'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[9999] overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md', className, )} {...props} From 240f497dda5902e0390c9dff108bb950c4d4aead Mon Sep 17 00:00:00 2001 From: SuveenE Date: Sat, 18 Oct 2025 21:34:28 +0800 Subject: [PATCH 09/12] Save project title --- .../src/app/projects/[project_id]/page.tsx | 89 ++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/projects/[project_id]/page.tsx b/frontend/src/app/projects/[project_id]/page.tsx index 866c16e..c8e7574 100644 --- a/frontend/src/app/projects/[project_id]/page.tsx +++ b/frontend/src/app/projects/[project_id]/page.tsx @@ -5,8 +5,9 @@ import { useParams } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; import { generateImage } from '@/actions/image'; -import { DEFAULT_USER_ID } from '@/actions/projects'; +import { DEFAULT_USER_ID, updateProject } from '@/actions/projects'; import { useProject } from '@/hooks/useProject'; +import { useQueryClient } from '@tanstack/react-query'; import { Tldraw, createShapeId } from 'tldraw'; import 'tldraw/tldraw.css'; @@ -24,6 +25,7 @@ interface WindowWithSpeechRecognition extends Window { export default function ProjectCanvasPage() { const params = useParams(); const projectId = params.project_id as string; + const queryClient = useQueryClient(); // Fetch project data const { data: project } = useProject(projectId, DEFAULT_USER_ID); @@ -52,6 +54,9 @@ export default function ProjectCanvasPage() { const autoGenerateTimerRef = useRef(null); const transcriptRef = useRef(''); const handleGenerateRef = useRef<(() => Promise) | null>(null); + const [isEditingName, setIsEditingName] = useState(false); + const [editedName, setEditedName] = useState(''); + const nameInputRef = useRef(null); // Helper to determine current mode (Agent Mode = empty canvas, Ask Mode = has content) const isAgentMode = useCallback((): boolean => { @@ -583,13 +588,93 @@ export default function ProjectCanvasPage() { handleGenerateRef.current = handleGenerate; }, [handleGenerate]); + // Start editing project name + const startEditingName = useCallback(() => { + if (project?.name) { + setEditedName(project.name); + setIsEditingName(true); + } + }, [project?.name]); + + // Focus input when editing starts + useEffect(() => { + if (isEditingName && nameInputRef.current) { + nameInputRef.current.focus(); + nameInputRef.current.select(); + } + }, [isEditingName]); + + // Save edited project name + const saveProjectName = useCallback(async () => { + if (!editedName.trim() || !project) { + setIsEditingName(false); + return; + } + + if (editedName.trim() === project.name) { + setIsEditingName(false); + return; + } + + try { + await updateProject(projectId, DEFAULT_USER_ID, { + name: editedName.trim(), + }); + // Invalidate and refetch the project data + await queryClient.invalidateQueries({ queryKey: ['project', projectId] }); + setIsEditingName(false); + } catch (err) { + console.error('Error updating project name:', err); + setError(err instanceof Error ? err.message : 'Failed to update project name'); + setIsEditingName(false); + } + }, [editedName, project, projectId, queryClient]); + + // Cancel editing project name + const cancelEditingName = useCallback(() => { + setIsEditingName(false); + setEditedName(''); + }, []); + + // Handle keyboard events for name editing + const handleNameKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + saveProjectName(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelEditingName(); + } + }, + [saveProjectName, cancelEditingName], + ); + return (
{/* Left Side - Drawing Canvas */}
Projects/ - {project?.name || 'Loading...'} + {isEditingName ? ( + setEditedName(e.target.value)} + onBlur={saveProjectName} + onKeyDown={handleNameKeyDown} + className="mx-1 border-none bg-transparent px-1 text-gray-900 underline outline-none" + /> + ) : ( + + {project?.name || 'Loading...'} + + )}
Date: Sat, 18 Oct 2025 21:46:03 +0800 Subject: [PATCH 10/12] Save snapshot --- frontend/src/actions/projects.ts | 1 + .../src/app/projects/[project_id]/page.tsx | 129 +++++++++++++++--- 2 files changed, 108 insertions(+), 22 deletions(-) diff --git a/frontend/src/actions/projects.ts b/frontend/src/actions/projects.ts index 8dad3ef..0ac50e6 100644 --- a/frontend/src/actions/projects.ts +++ b/frontend/src/actions/projects.ts @@ -7,6 +7,7 @@ export interface Project { user_id: string; name: string; description?: string; + snapshot?: Record; created_at: string; updated_at: string; } diff --git a/frontend/src/app/projects/[project_id]/page.tsx b/frontend/src/app/projects/[project_id]/page.tsx index c8e7574..403bbb7 100644 --- a/frontend/src/app/projects/[project_id]/page.tsx +++ b/frontend/src/app/projects/[project_id]/page.tsx @@ -8,7 +8,7 @@ import { generateImage } from '@/actions/image'; import { DEFAULT_USER_ID, updateProject } from '@/actions/projects'; import { useProject } from '@/hooks/useProject'; import { useQueryClient } from '@tanstack/react-query'; -import { Tldraw, createShapeId } from 'tldraw'; +import { Tldraw, createShapeId, getSnapshot, loadSnapshot } from 'tldraw'; import 'tldraw/tldraw.css'; import { BeforeAfterSlider } from '@/components/canvas/before-after-slider'; @@ -57,6 +57,9 @@ export default function ProjectCanvasPage() { const [isEditingName, setIsEditingName] = useState(false); const [editedName, setEditedName] = useState(''); const nameInputRef = useRef(null); + const [isSaving, setIsSaving] = useState(false); + const saveDebounceTimerRef = useRef(null); + const hasLoadedSnapshotRef = useRef(false); // Helper to determine current mode (Agent Mode = empty canvas, Ask Mode = has content) const isAgentMode = useCallback((): boolean => { @@ -650,31 +653,113 @@ export default function ProjectCanvasPage() { [saveProjectName, cancelEditingName], ); + // Save canvas state to database + const saveCanvas = useCallback(async () => { + if (!editorRef.current) { + return; + } + + setIsSaving(true); + + try { + const editor = editorRef.current; + const snapshot = getSnapshot(editor.store); + + await updateProject(projectId, DEFAULT_USER_ID, { + snapshot: snapshot.document as unknown as Record, + }); + + console.log('Canvas saved successfully'); + } catch (err) { + console.error('Error saving canvas:', err); + setError(err instanceof Error ? err.message : 'Failed to save canvas'); + } finally { + setIsSaving(false); + } + }, [projectId]); + + // Load canvas state from database when project loads + useEffect(() => { + if (!editorRef.current || !project?.snapshot || !frameId || hasLoadedSnapshotRef.current) + return; + + try { + const editor = editorRef.current; + // Load the snapshot into the store as a remote change to avoid triggering auto-save + editor.store.mergeRemoteChanges(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + loadSnapshot(editor.store, { document: project.snapshot as any }); + }); + hasLoadedSnapshotRef.current = true; + console.log('Canvas loaded from database'); + } catch (err) { + console.error('Error loading canvas:', err); + setError(err instanceof Error ? err.message : 'Failed to load canvas'); + } + }, [project?.snapshot, frameId]); + + // Auto-save with debounce - listen for canvas changes + useEffect(() => { + if (!editorRef.current || !frameId) return; + + const editor = editorRef.current; + + // Listen for changes to the document + const unlisten = editor.store.listen( + () => { + // Clear any existing timer + if (saveDebounceTimerRef.current) { + clearTimeout(saveDebounceTimerRef.current); + } + + // Set new timer to save after 2.5 seconds of inactivity + saveDebounceTimerRef.current = setTimeout(() => { + // Only save if we've loaded the initial snapshot + if (hasLoadedSnapshotRef.current) { + saveCanvas(); + } + }, 2500); + }, + { scope: 'document', source: 'user' }, + ); + + // Cleanup + return () => { + unlisten(); + if (saveDebounceTimerRef.current) { + clearTimeout(saveDebounceTimerRef.current); + } + }; + }, [frameId, saveCanvas]); + return (
{/* Left Side - Drawing Canvas */}
-
- Projects/ - {isEditingName ? ( - setEditedName(e.target.value)} - onBlur={saveProjectName} - onKeyDown={handleNameKeyDown} - className="mx-1 border-none bg-transparent px-1 text-gray-900 underline outline-none" - /> - ) : ( - - {project?.name || 'Loading...'} - - )} +
+
+ Projects/ + {isEditingName ? ( + setEditedName(e.target.value)} + onBlur={saveProjectName} + onKeyDown={handleNameKeyDown} + className="mx-1 border-none bg-transparent px-1 text-gray-900 underline outline-none" + /> + ) : ( + + {project?.name || 'Loading...'} + + )} +
+ {isSaving && Saving...}
Date: Sat, 18 Oct 2025 21:47:05 +0800 Subject: [PATCH 11/12] Lint --- backend/app/controllers/project.py | 8 ++------ backend/app/services/project.py | 7 +++---- backend/app/utils/database.py | 1 - 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/backend/app/controllers/project.py b/backend/app/controllers/project.py index 12590b2..478ad4e 100644 --- a/backend/app/controllers/project.py +++ b/backend/app/controllers/project.py @@ -2,12 +2,8 @@ from fastapi import APIRouter, Header, HTTPException -from app.models.project import ( - Project, - ProjectCreateRequest, - ProjectListResponse, - ProjectUpdateRequest, -) +from app.models.project import (Project, ProjectCreateRequest, + ProjectListResponse, ProjectUpdateRequest) from app.services.project import ProjectService from app.utils.database import db_client diff --git a/backend/app/services/project.py b/backend/app/services/project.py index bfbca9f..ac6e3e8 100644 --- a/backend/app/services/project.py +++ b/backend/app/services/project.py @@ -4,7 +4,8 @@ from supabase._async.client import AsyncClient as Client -from app.models.project import Project, ProjectCreateRequest, ProjectUpdateRequest +from app.models.project import (Project, ProjectCreateRequest, + ProjectUpdateRequest) log = logging.getLogger(__name__) @@ -76,9 +77,7 @@ async def create_project( # Insert into projects table response = ( - await supabase_client.table("projects") - .insert(new_project) - .execute() + await supabase_client.table("projects").insert(new_project).execute() ) if not response.data or len(response.data) == 0: 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 From 5cc6e436aac351024520f2bfdeafc7c3a59c0536 Mon Sep 17 00:00:00 2001 From: SuveenE Date: Sat, 18 Oct 2025 21:49:45 +0800 Subject: [PATCH 12/12] Isort --- backend/app/controllers/project.py | 8 ++++++-- backend/app/services/project.py | 3 +-- backend/app/utils/database.py | 1 + 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/app/controllers/project.py b/backend/app/controllers/project.py index 478ad4e..12590b2 100644 --- a/backend/app/controllers/project.py +++ b/backend/app/controllers/project.py @@ -2,8 +2,12 @@ from fastapi import APIRouter, Header, HTTPException -from app.models.project import (Project, ProjectCreateRequest, - ProjectListResponse, ProjectUpdateRequest) +from app.models.project import ( + Project, + ProjectCreateRequest, + ProjectListResponse, + ProjectUpdateRequest, +) from app.services.project import ProjectService from app.utils.database import db_client diff --git a/backend/app/services/project.py b/backend/app/services/project.py index ac6e3e8..6cf3c69 100644 --- a/backend/app/services/project.py +++ b/backend/app/services/project.py @@ -4,8 +4,7 @@ from supabase._async.client import AsyncClient as Client -from app.models.project import (Project, ProjectCreateRequest, - ProjectUpdateRequest) +from app.models.project import Project, ProjectCreateRequest, ProjectUpdateRequest log = logging.getLogger(__name__) diff --git a/backend/app/utils/database.py b/backend/app/utils/database.py index cb37e76..81b4619 100644 --- a/backend/app/utils/database.py +++ b/backend/app/utils/database.py @@ -3,6 +3,7 @@ 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