Spaces:
Runtime error
Runtime error
| /** | |
| * @license | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| /* tslint:disable */ | |
| import {ContentUnion, GoogleGenAI, Modality} from '@google/genai'; | |
| import {LoaderCircle, SendHorizontal, Trash2, X, Eraser} from 'lucide-react'; // Added Eraser | |
| import {useEffect, useRef, useState} from 'react'; | |
| function Home() { | |
| const [apiKey, setApiKey] = useState<string | null>(null); | |
| const [ai, setAi] = useState<GoogleGenAI | null>(null); | |
| const [keyError, setKeyError] = useState<string | null>(null); | |
| useEffect(() => { | |
| async function fetchKey() { | |
| try { | |
| const response = await fetch('/api/get-api-key'); // Assuming Nginx proxies this | |
| if (!response.ok) { | |
| throw new Error(`Failed to fetch API key: ${response.statusText}`); | |
| } | |
| const data = await response.json(); | |
| if (data.apiKey) { | |
| setApiKey(data.apiKey); | |
| setAi(new GoogleGenAI({ apiKey: data.apiKey })); | |
| } else { | |
| throw new Error(data.error || "API Key not returned from backend"); | |
| } | |
| } catch (error: any) { | |
| console.error("Error fetching API key:", error); | |
| setKeyError(error.message); | |
| // Show error to user | |
| } | |
| } | |
| fetchKey(); | |
| }, []); | |
| function parseError(error: string) { | |
| try { | |
| const regex = /{"error":(.*)}/gm; | |
| const m = regex.exec(error); | |
| if (m && m[1]) { | |
| const e = m[1]; | |
| const err = JSON.parse(e); | |
| return err.message || error; | |
| } | |
| } catch (e) { /* ignore */ } | |
| return error; | |
| } | |
| export default function Home() { | |
| const canvasRef = useRef<HTMLCanvasElement>(null); | |
| const backgroundImageRef = useRef<HTMLImageElement | null>(null); // To store the uploaded image object | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const [uploadedImageFile, setUploadedImageFile] = useState<File | null>(null); | |
| // previewUrl is mostly for the initial load into Image object, canvas is the main display | |
| // const [previewUrl, setPreviewUrl] = useState<string | null>(null); | |
| const [isDrawing, setIsDrawing] = useState(false); | |
| const [penColor, setPenColor] = useState('#000000'); | |
| const colorInputRef = useRef<HTMLInputElement>(null); | |
| const [prompt, setPrompt] = useState(''); | |
| const [generatedImage, setGeneratedImage] = useState<string | null>(null); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [showErrorModal, setShowErrorModal] = useState(false); | |
| const [errorMessage, setErrorMessage] = useState(''); | |
| // Initialize canvas with white background or uploaded image | |
| const initializeCanvas = (clearWhite: boolean = true) => { | |
| if (!canvasRef.current) return; | |
| const canvas = canvasRef.current; | |
| const ctx = canvas.getContext('2d')!; | |
| ctx.fillStyle = '#FFFFFF'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| if (!clearWhite && backgroundImageRef.current) { | |
| ctx.drawImage( | |
| backgroundImageRef.current, | |
| 0, | |
| 0, | |
| canvas.width, | |
| canvas.height, | |
| ); | |
| } | |
| }; | |
| // Effect for API key check | |
| useEffect(() => { | |
| if (!API_KEY) { | |
| setErrorMessage("Gemini API Key is missing. Please configure it to use the application."); | |
| setShowErrorModal(true); | |
| } | |
| initializeCanvas(true); // Initialize canvas on mount | |
| }, []); | |
| // Load uploaded image onto canvas | |
| useEffect(() => { | |
| if (uploadedImageFile) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| const img = new window.Image(); | |
| img.onload = () => { | |
| backgroundImageRef.current = img; | |
| initializeCanvas(false); // Clear canvas and draw new image | |
| setGeneratedImage(null); // Clear previous AI generated image | |
| }; | |
| img.onerror = () => { | |
| setErrorMessage("Failed to load the uploaded image."); | |
| setShowErrorModal(true); | |
| backgroundImageRef.current = null; | |
| initializeCanvas(true); // Clear to white on error | |
| } | |
| img.src = e.target?.result as string; | |
| }; | |
| reader.readAsDataURL(uploadedImageFile); | |
| } else { | |
| // No file uploaded, or file cleared | |
| backgroundImageRef.current = null; | |
| initializeCanvas(true); // Clear to white | |
| } | |
| }, [uploadedImageFile]); | |
| // Drawing functions | |
| const getCoordinates = (e: React.MouseEvent | React.TouchEvent) => { | |
| const canvas = canvasRef.current!; | |
| const rect = canvas.getBoundingClientRect(); | |
| const scaleX = canvas.width / rect.width; | |
| const scaleY = canvas.height / rect.height; | |
| let clientX, clientY; | |
| if (e.nativeEvent instanceof MouseEvent) { | |
| clientX = e.nativeEvent.offsetX * scaleX; | |
| clientY = e.nativeEvent.offsetY * scaleY; | |
| } else if (e.nativeEvent instanceof TouchEvent && e.nativeEvent.touches?.[0]) { | |
| clientX = (e.nativeEvent.touches[0].clientX - rect.left) * scaleX; | |
| clientY = (e.nativeEvent.touches[0].clientY - rect.top) * scaleY; | |
| } else { // Fallback for other PointerEvents or if offsetX/Y are not directly available | |
| clientX = (('clientX' in e.nativeEvent ? e.nativeEvent.clientX : (e.nativeEvent as any).layerX) - rect.left) * scaleX; | |
| clientY = (('clientY' in e.nativeEvent ? e.nativeEvent.clientY : (e.nativeEvent as any).layerY) - rect.top) * scaleY; | |
| } | |
| return { x: clientX, y: clientY }; | |
| }; | |
| const startDrawing = (e: React.MouseEvent | React.TouchEvent) => { | |
| if (!canvasRef.current) return; | |
| const { x, y } = getCoordinates(e); | |
| const ctx = canvasRef.current.getContext('2d')!; | |
| if (e.type === 'touchstart') e.preventDefault(); | |
| ctx.beginPath(); | |
| ctx.moveTo(x, y); | |
| setIsDrawing(true); | |
| }; | |
| const draw = (e: React.MouseEvent | React.TouchEvent) => { | |
| if (!isDrawing || !canvasRef.current) return; | |
| if (e.type === 'touchmove') e.preventDefault(); | |
| const { x, y } = getCoordinates(e); | |
| const ctx = canvasRef.current.getContext('2d')!; | |
| ctx.lineWidth = 5; | |
| ctx.lineCap = 'round'; | |
| ctx.strokeStyle = penColor; | |
| ctx.lineTo(x, y); | |
| ctx.stroke(); | |
| }; | |
| const stopDrawing = () => { | |
| if (isDrawing) { | |
| const ctx = canvasRef.current?.getContext('2d'); | |
| if (ctx) ctx.closePath(); // Close the path, though stroke() already renders | |
| setIsDrawing(false); | |
| } | |
| }; | |
| const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = event.target.files?.[0]; | |
| if (file) { | |
| setUploadedImageFile(file); // This will trigger the useEffect to draw it | |
| } | |
| }; | |
| // Clears only the drawings, keeps the uploaded image | |
| const clearDrawingLayer = () => { | |
| initializeCanvas(false); // Redraws background image, effectively clearing drawings | |
| }; | |
| // Clears everything: uploaded image, drawings, generated image | |
| const resetAll = () => { | |
| setUploadedImageFile(null); // This will trigger useEffect to clear canvas to white | |
| backgroundImageRef.current = null; | |
| setGeneratedImage(null); | |
| setPrompt(''); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ""; | |
| } | |
| initializeCanvas(true); // Ensure it's white | |
| }; | |
| const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| setPenColor(e.target.value); | |
| }; | |
| const openColorPicker = () => { | |
| colorInputRef.current?.click(); | |
| }; | |
| const handleKeyDownForColorPicker = (e: React.KeyboardEvent) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| openColorPicker(); | |
| } | |
| }; | |
| const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { | |
| e.preventDefault(); | |
| if (!ai) { | |
| setErrorMessage("Gemini API client is not initialized. Check API Key."); | |
| setShowErrorModal(true); | |
| return; | |
| } | |
| if (!canvasRef.current || !backgroundImageRef.current) { // Ensure an image was uploaded | |
| setErrorMessage('Please upload an image first.'); | |
| setShowErrorModal(true); | |
| return; | |
| } | |
| if (!prompt.trim()) { | |
| setErrorMessage('Please enter a prompt.'); | |
| setShowErrorModal(true); | |
| return; | |
| } | |
| setIsLoading(true); | |
| setGeneratedImage(null); | |
| try { | |
| // Get the current canvas content (uploaded image + drawings) as base64 | |
| const canvas = canvasRef.current; | |
| const drawingAndImageData = canvas.toDataURL('image/png').split(',')[1]; | |
| const imageMimeType = 'image/png'; // We are sending PNG from canvas | |
| console.log('Request payload:', { | |
| prompt, | |
| imageMimeType, | |
| imageData: drawingAndImageData ? `${drawingAndImageData.substring(0, 50)}... (truncated)` : null, | |
| }); | |
| let contents: ContentUnion[] = [ | |
| { | |
| role: 'USER', | |
| parts: [ | |
| { inlineData: { data: drawingAndImageData, mimeType: imageMimeType } }, | |
| { text: `${prompt}. Modify the image based on the prompt and any drawings.` }, // Added context for drawings | |
| ], | |
| }, | |
| ]; | |
| const response = await ai.models.generateContent({ | |
| model: 'gemini-2.0-flash-preview-image-generation', | |
| contents, | |
| config: { | |
| responseModalities: [Modality.TEXT, Modality.IMAGE], | |
| }, | |
| }); | |
| const apiResponseData = { success: true, message: '', imageData: null, error: undefined }; | |
| for (const part of response.candidates[0].content.parts) { | |
| if (part.text) apiResponseData.message = part.text; | |
| else if (part.inlineData) apiResponseData.imageData = part.inlineData.data; | |
| } | |
| console.log('API Response:', { | |
| ...apiResponseData, | |
| imageData: apiResponseData.imageData ? `${apiResponseData.imageData.substring(0, 50)}... (truncated)` : null, | |
| }); | |
| if (apiResponseData.success && apiResponseData.imageData) { | |
| const imageUrl = `data:image/png;base64,${apiResponseData.imageData}`; | |
| setGeneratedImage(imageUrl); | |
| } else { | |
| const errorMsg = apiResponseData.error || apiResponseData.message || 'Failed to generate image from API response.'; | |
| setErrorMessage(errorMsg); | |
| setShowErrorModal(true); | |
| } | |
| } catch (error: any) { | |
| console.error('Error submitting image and prompt:', error); | |
| setErrorMessage(parseError(error.message || 'An unexpected error occurred.')); | |
| setShowErrorModal(true); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| const closeErrorModal = () => setShowErrorModal(false); | |
| // Add touch event prevention for drawing | |
| useEffect(() => { | |
| const canvas = canvasRef.current; | |
| const preventTouchDefault = (e: TouchEvent) => { | |
| if (isDrawing && canvas && canvas.contains(e.target as Node)) { | |
| e.preventDefault(); | |
| } | |
| }; | |
| if (canvas) { | |
| canvas.addEventListener('touchstart', preventTouchDefault, { passive: false }); | |
| canvas.addEventListener('touchmove', preventTouchDefault, { passive: false }); | |
| } | |
| return () => { | |
| if (canvas) { | |
| canvas.removeEventListener('touchstart', preventTouchDefault); | |
| canvas.removeEventListener('touchmove', preventTouchDefault); | |
| } | |
| }; | |
| }, [isDrawing]); | |
| return ( | |
| <> | |
| <div className="min-h-screen notebook-paper-bg text-gray-900 flex flex-col justify-start items-center"> | |
| <main className="container mx-auto px-3 sm:px-6 py-5 sm:py-10 pb-32 max-w-5xl w-full"> | |
| <div className="flex flex-col sm:flex-row sm:justify-between sm:items-end mb-2 sm:mb-6 gap-2"> | |
| <div> | |
| <h1 className="text-2xl sm:text-3xl font-bold mb-0 leading-tight font-mega"> | |
| Gemini Image Editor | |
| </h1> | |
| <p className="text-sm sm:text-base text-gray-500 mt-1"> | |
| Upload an image, draw on it, and prompt Gemini to modify! | |
| </p> | |
| <p className="text-sm sm:text-base text-gray-500 mt-1"> | |
| Built with{' '} | |
| <a className="underline" href="https://ai.google.dev/gemini-api/docs/image-generation" target="_blank" rel="noopener noreferrer"> | |
| Gemini API | |
| </a> | |
| </p> | |
| </div> | |
| <menu className="flex items-center bg-gray-300 rounded-full p-2 shadow-sm self-start sm:self-auto"> | |
| <button | |
| type="button" | |
| className="w-10 h-10 rounded-full overflow-hidden mr-2 flex items-center justify-center border-2 border-white shadow-sm transition-transform hover:scale-110 disabled:opacity-50 disabled:cursor-not-allowed" | |
| onClick={openColorPicker} | |
| onKeyDown={handleKeyDownForColorPicker} | |
| aria-label="Open color picker" | |
| style={{ backgroundColor: penColor }} | |
| disabled={!backgroundImageRef.current} | |
| title="Select Pen Color"> | |
| <input | |
| ref={colorInputRef} | |
| type="color" | |
| value={penColor} | |
| onChange={handleColorChange} | |
| className="opacity-0 absolute w-px h-px" | |
| aria-label="Select pen color" | |
| /> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={clearDrawingLayer} | |
| className="w-10 h-10 rounded-full flex items-center justify-center bg-white shadow-sm transition-all hover:bg-gray-50 hover:scale-110 mr-2 disabled:opacity-50 disabled:cursor-not-allowed" | |
| aria-label="Clear Drawings" | |
| title="Clear Drawings (keeps uploaded image)" | |
| disabled={!backgroundImageRef.current}> | |
| <Eraser className="w-5 h-5 text-gray-700" /> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={resetAll} | |
| className="w-10 h-10 rounded-full flex items-center justify-center bg-white shadow-sm transition-all hover:bg-gray-50 hover:scale-110" | |
| aria-label="Clear All (Image, Drawings, Prompt)" | |
| title="Clear All (Image, Drawings, Prompt)"> | |
| <Trash2 className="w-5 h-5 text-gray-700"/> | |
| </button> | |
| </menu> | |
| </div> | |
| {/* Image upload and canvas area */} | |
| <div className="w-full mb-6 p-1 sm:p-2 bg-white/90 border-2 border-gray-300 rounded-lg shadow"> | |
| <div className="mb-4 px-2 sm:px-0"> | |
| <label htmlFor="imageUpload" className="block text-sm font-medium text-gray-700 mb-1"> | |
| Upload your image (max 4MB recommended for Gemini): | |
| </label> | |
| <input | |
| id="imageUpload" | |
| type="file" | |
| accept="image/*" | |
| onChange={handleImageUpload} | |
| ref={fileInputRef} | |
| className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-gray-200 file:text-gray-700 hover:file:bg-gray-300" | |
| /> | |
| </div> | |
| <canvas | |
| ref={canvasRef} | |
| width={960} // Internal resolution | |
| height={540} // Internal resolution | |
| onMouseDown={startDrawing} | |
| onMouseMove={draw} | |
| onMouseUp={stopDrawing} | |
| onMouseLeave={stopDrawing} | |
| onTouchStart={startDrawing} | |
| onTouchMove={draw} | |
| onTouchEnd={stopDrawing} | |
| className={`border-2 border-black w-full hover:cursor-crosshair sm:h-[60vh] h-[40vh] min-h-[300px] bg-white/90 touch-none ${backgroundImageRef.current ? '' : 'cursor-not-allowed'}`} | |
| style={{ touchAction: backgroundImageRef.current ? 'none' : 'auto' }} // Prevent scroll only when drawing is possible | |
| /> | |
| </div> | |
| {/* AI Generated Image Display */} | |
| {generatedImage && ( | |
| <div className="w-full mb-6 p-4 bg-gray-100 border-2 border-gray-400 rounded-lg shadow"> | |
| <h3 className="text-center text-lg text-gray-700 mb-2 font-bold">AI Generated Image:</h3> | |
| <img src={generatedImage} alt="Generated by AI" className="max-w-full h-auto max-h-[300px] sm:max-h-[540px] mx-auto rounded border border-gray-300" /> | |
| </div> | |
| )} | |
| <form onSubmit={handleSubmit} className="w-full"> | |
| <div className="relative"> | |
| <input | |
| type="text" | |
| value={prompt} | |
| onChange={(e) => setPrompt(e.target.value)} | |
| placeholder="Describe changes or what to generate from the image & drawings..." | |
| className="w-full p-3 sm:p-4 pr-12 sm:pr-14 text-sm sm:text-base border-2 border-black bg-white text-gray-800 shadow-sm focus:ring-2 focus:ring-gray-200 focus:outline-none transition-all font-mono" | |
| required | |
| disabled={!API_KEY || !backgroundImageRef.current} | |
| /> | |
| <button | |
| type="submit" | |
| disabled={isLoading || !API_KEY || !backgroundImageRef.current || !prompt.trim()} | |
| className="absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 p-1.5 sm:p-2 rounded-none bg-black text-white hover:cursor-pointer hover:bg-gray-800 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"> | |
| {isLoading ? ( | |
| <LoaderCircle className="w-5 sm:w-6 h-5 sm:h-6 animate-spin" aria-label="Loading"/> | |
| ) : ( | |
| <SendHorizontal className="w-5 sm:w-6 h-5 sm:h-6" aria-label="Submit"/> | |
| )} | |
| </button> | |
| </div> | |
| </form> | |
| </main> | |
| {showErrorModal && ( | |
| <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> | |
| <div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6"> | |
| <div className="flex justify-between items-start mb-4"> | |
| <h3 className="text-xl font-bold text-red-600">Error</h3> | |
| <button onClick={closeErrorModal} className="text-gray-400 hover:text-gray-500"> | |
| <X className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| <p className="font-medium text-gray-700 whitespace-pre-wrap"> | |
| {parseError(errorMessage)} | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </> | |
| ); | |
| } |