import { useState, useRef, useEffect } from "react"; import { SendHorizontal, LoaderCircle, Trash2, X } from "lucide-react"; import Head from "next/head"; export default function Home() { const canvasRef = useRef(null); const backgroundImageRef = useRef(null); const [isDrawing, setIsDrawing] = useState(false); const [penColor, setPenColor] = useState("#000000"); const colorInputRef = useRef(null); const [prompt, setPrompt] = useState(""); const [generatedImage, setGeneratedImage] = useState(null); const [isLoading, setIsLoading] = useState(false); const [showErrorModal, setShowErrorModal] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const [customApiKey, setCustomApiKey] = useState(""); // Load background image when generatedImage changes useEffect(() => { if (generatedImage && canvasRef.current) { // Use the window.Image constructor to avoid conflict with Next.js Image component const img = new window.Image(); img.onload = () => { backgroundImageRef.current = img; drawImageToCanvas(); }; img.src = generatedImage; } }, [generatedImage]); // Initialize canvas with white background when component mounts useEffect(() => { if (canvasRef.current) { initializeCanvas(); } }, []); // Initialize canvas with white background const initializeCanvas = () => { const canvas = canvasRef.current; const ctx = canvas.getContext("2d"); // Fill canvas with white background ctx.fillStyle = "#FFFFFF"; ctx.fillRect(0, 0, canvas.width, canvas.height); }; // Draw the background image to the canvas const drawImageToCanvas = () => { if (!canvasRef.current || !backgroundImageRef.current) return; const canvas = canvasRef.current; const ctx = canvas.getContext("2d"); // Fill with white background first ctx.fillStyle = "#FFFFFF"; ctx.fillRect(0, 0, canvas.width, canvas.height); // Draw the background image ctx.drawImage( backgroundImageRef.current, 0, 0, canvas.width, canvas.height ); }; // Get the correct coordinates based on canvas scaling const getCoordinates = (e) => { const canvas = canvasRef.current; const rect = canvas.getBoundingClientRect(); // Calculate the scaling factor between the internal canvas size and displayed size const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; // Apply the scaling to get accurate coordinates return { x: (e.nativeEvent.offsetX || (e.nativeEvent.touches?.[0]?.clientX - rect.left)) * scaleX, y: (e.nativeEvent.offsetY || (e.nativeEvent.touches?.[0]?.clientY - rect.top)) * scaleY }; }; const startDrawing = (e) => { const canvas = canvasRef.current; const ctx = canvas.getContext("2d"); const { x, y } = getCoordinates(e); // Prevent default behavior to avoid scrolling on touch devices if (e.type === 'touchstart') { e.preventDefault(); } // Start a new path without clearing the canvas ctx.beginPath(); ctx.moveTo(x, y); setIsDrawing(true); }; const draw = (e) => { if (!isDrawing) return; // Prevent default behavior to avoid scrolling on touch devices if (e.type === 'touchmove') { e.preventDefault(); } const canvas = canvasRef.current; const ctx = canvas.getContext("2d"); const { x, y } = getCoordinates(e); ctx.lineWidth = 5; ctx.lineCap = "round"; ctx.strokeStyle = penColor; ctx.lineTo(x, y); ctx.stroke(); }; const stopDrawing = () => { setIsDrawing(false); }; const clearCanvas = () => { const canvas = canvasRef.current; const ctx = canvas.getContext("2d"); // Fill with white instead of just clearing ctx.fillStyle = "#FFFFFF"; ctx.fillRect(0, 0, canvas.width, canvas.height); setGeneratedImage(null); backgroundImageRef.current = null; }; const handleColorChange = (e) => { setPenColor(e.target.value); }; const openColorPicker = () => { if (colorInputRef.current) { colorInputRef.current.click(); } }; const handleKeyDown = (e) => { if (e.key === 'Enter' || e.key === ' ') { openColorPicker(); } }; const handleSubmit = async (e) => { e.preventDefault(); if (!canvasRef.current) return; setIsLoading(true); try { // Get the drawing as base64 data const canvas = canvasRef.current; // Create a temporary canvas to add white background const tempCanvas = document.createElement('canvas'); tempCanvas.width = canvas.width; tempCanvas.height = canvas.height; const tempCtx = tempCanvas.getContext('2d'); // Fill with white background tempCtx.fillStyle = '#FFFFFF'; tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); // Draw the original canvas content on top of the white background tempCtx.drawImage(canvas, 0, 0); const drawingData = tempCanvas.toDataURL("image/png").split(",")[1]; // Create request payload const requestPayload = { prompt, drawingData, customApiKey // Add the custom API key to the payload if it exists }; // Log the request payload (without the full image data for brevity) console.log("Request payload:", { ...requestPayload, drawingData: drawingData ? `${drawingData.substring(0, 50)}... (truncated)` : null, customApiKey: customApiKey ? "**********" : null }); // Send the drawing and prompt to the API const response = await fetch("/api/generate", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(requestPayload), }); const data = await response.json(); // Log the response (without the full image data for brevity) console.log("Response:", { ...data, imageData: data.imageData ? `${data.imageData.substring(0, 50)}... (truncated)` : null }); if (data.success && data.imageData) { const imageUrl = `data:image/png;base64,${data.imageData}`; setGeneratedImage(imageUrl); } else { console.error("Failed to generate image:", data.error); // Check if the error is related to quota exhaustion or other API errors if (data.error && ( data.error.includes("Resource has been exhausted") || data.error.includes("quota") || response.status === 429 || response.status === 500 )) { setErrorMessage(data.error); setShowErrorModal(true); } else { alert("Failed to generate image. Please try again."); } } } catch (error) { console.error("Error submitting drawing:", error); setErrorMessage(error.message || "An unexpected error occurred."); setShowErrorModal(true); } finally { setIsLoading(false); } }; // Close the error modal const closeErrorModal = () => { setShowErrorModal(false); }; // Handle the custom API key submission const handleApiKeySubmit = (e) => { e.preventDefault(); setShowErrorModal(false); // Will use the customApiKey state in the next API call }; // Add touch event prevention function useEffect(() => { // Function to prevent default touch behavior on canvas const preventTouchDefault = (e) => { if (isDrawing) { e.preventDefault(); } }; // Add event listener when component mounts const canvas = canvasRef.current; if (canvas) { canvas.addEventListener('touchstart', preventTouchDefault, { passive: false }); canvas.addEventListener('touchmove', preventTouchDefault, { passive: false }); } // Remove event listener when component unmounts return () => { if (canvas) { canvas.removeEventListener('touchstart', preventTouchDefault); canvas.removeEventListener('touchmove', preventTouchDefault); } }; }, [isDrawing]); return ( <> <Head> <title>Gemini Co-Drawing</title> <meta name="description" content="Gemini Co-Drawing" /> <link rel="icon" href="/favicon.ico" /> </Head> <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"> {/* Header section with title and tools */} <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 Co-Drawing</h1> <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 2.0 native image generation </a> </p> <p className="text-sm sm:text-base text-gray-500 mt-1"> by{" "} <a className="underline" href="https://x.com/trudypainter" target="_blank" rel="noopener noreferrer"> @trudypainter </a> {" "}and{" "} <a className="underline" href="https://x.com/alexanderchen" target="_blank" rel="noopener noreferrer"> @alexanderchen </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" onClick={openColorPicker} onKeyDown={handleKeyDown} aria-label="Open color picker" style={{ backgroundColor: penColor }} > <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={clearCanvas} 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" > <Trash2 className="w-5 h-5 text-gray-700" aria-label="Clear Canvas" /> </button> </menu> </div> {/* Canvas section with notebook paper background */} <div className="w-full mb-6"> <canvas ref={canvasRef} width={960} height={540} 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-[30vh] min-h-[320px] bg-white/90 touch-none" /> </div> {/* Input form that matches canvas width */} <form onSubmit={handleSubmit} className="w-full"> <div className="relative"> <input type="text" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Add your change..." 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 /> <button type="submit" disabled={isLoading} 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-300 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> {/* Error Modal */} {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-gray-700">Failed to generate</h3> <button onClick={closeErrorModal} className="text-gray-400 hover:text-gray-500" > <X className="w-5 h-5" /> </button> </div> <form onSubmit={handleApiKeySubmit} className="mb-4"> <label className="block text-sm font-medium text-gray-600 mb-2"> This space is pretty popular... add your own Gemini API key from <a href="https://ai.google.dev/" target="_blank" rel="noopener noreferrer" className="underline" > Google AI Studio </a>: </label> <input type="text" value={customApiKey} onChange={(e) => setCustomApiKey(e.target.value)} placeholder="API Key..." className="w-full p-3 border border-gray-300 rounded mb-4 font-mono text-sm" required /> <div className="flex justify-end gap-2"> <button type="button" onClick={closeErrorModal} className="px-4 py-2 text-sm border border-gray-300 rounded hover:bg-gray-50" > Cancel </button> <button type="submit" className="px-4 py-2 text-sm bg-black text-white rounded hover:bg-gray-800" > Use My API Key </button> </div> </form> </div> </div> )} </div> </> ); }