Spaces:
Running
Running
| /** | |
| * @license | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| /* tslint:disable */ | |
| import { | |
| ChevronDown, | |
| Library, | |
| LoaderCircle, | |
| Paintbrush, | |
| PictureInPicture, | |
| Redo2, | |
| SendHorizontal, | |
| Sparkles, | |
| Trash2, | |
| Undo2, | |
| X, | |
| } from 'lucide-react'; | |
| import {useEffect, useRef, useState} from 'react'; | |
| // This function remains useful for parsing potential error messages | |
| function parseError(error: string) { | |
| try { | |
| // Attempt to parse the error as a JSON object which the proxy might send | |
| const errObj = JSON.parse(error); | |
| return errObj.message || error; | |
| } catch (e) { | |
| // If it's not JSON, return the original error string | |
| const regex = /{"error":(.*)}/gm; | |
| const m = regex.exec(error); | |
| try { | |
| const e = m[1]; | |
| const err = JSON.parse(e); | |
| return err.message || error; | |
| } catch (e) { | |
| return error; | |
| } | |
| } | |
| } | |
| export default function Home() { | |
| const canvasRef = useRef<HTMLCanvasElement>(null); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const backgroundImageRef = useRef<HTMLImageElement | null>(null); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| const [isDrawing, setIsDrawing] = useState(false); | |
| const [prompt, setPrompt] = useState(''); | |
| const [generatedImage, setGeneratedImage] = useState<string | null>(null); | |
| const [multiImages, setMultiImages] = useState< | |
| {url: string; type: string}[] | |
| >([]); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [showErrorModal, setShowErrorModal] = useState(false); | |
| const [errorMessage, setErrorMessage] = useState(''); | |
| const [mode, setMode] = useState< | |
| 'canvas' | 'editor' | 'imageGen' | 'multi-img-edit' | |
| >('editor'); | |
| const [isDropdownOpen, setIsDropdownOpen] = useState(false); | |
| const [apiKey, setApiKey] = useState(''); | |
| const [showApiKeyModal, setShowApiKeyModal] = useState(false); | |
| // State for canvas history | |
| const [history, setHistory] = useState<string[]>([]); | |
| const [historyIndex, setHistoryIndex] = useState(-1); | |
| // When switching to canvas mode, initialize it and its history | |
| useEffect(() => { | |
| if (mode === 'canvas' && canvasRef.current) { | |
| const canvas = canvasRef.current; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.fillStyle = '#FFFFFF'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| // If an image already exists from another mode, draw it. | |
| if (generatedImage) { | |
| const img = new window.Image(); | |
| img.onload = () => { | |
| ctx.drawImage(img, 0, 0, canvas.width, canvas.height); | |
| // Save this as the initial state for this session | |
| const dataUrl = canvas.toDataURL(); | |
| setHistory([dataUrl]); | |
| setHistoryIndex(0); | |
| }; | |
| img.src = generatedImage; | |
| } else { | |
| // Otherwise, save the blank state as initial | |
| const dataUrl = canvas.toDataURL(); | |
| setHistory([dataUrl]); | |
| setHistoryIndex(0); | |
| } | |
| } | |
| }, [mode, generatedImage]); | |
| // Load background image when generatedImage changes | |
| useEffect(() => { | |
| if (generatedImage && canvasRef.current) { | |
| const img = new window.Image(); | |
| img.onload = () => { | |
| backgroundImageRef.current = img; | |
| drawImageToCanvas(); | |
| if (mode === 'canvas') { | |
| // A small timeout to let the draw happen before saving | |
| setTimeout(saveCanvasState, 50); | |
| } | |
| }; | |
| img.src = generatedImage; | |
| } | |
| }, [generatedImage, mode]); | |
| // Handle clicks outside the dropdown to close it | |
| useEffect(() => { | |
| function handleClickOutside(event: MouseEvent) { | |
| if ( | |
| dropdownRef.current && | |
| !dropdownRef.current.contains(event.target as Node) | |
| ) { | |
| setIsDropdownOpen(false); | |
| } | |
| } | |
| document.addEventListener('mousedown', handleClickOutside); | |
| return () => { | |
| document.removeEventListener('mousedown', handleClickOutside); | |
| }; | |
| }, [dropdownRef]); | |
| // Initialize canvas with white background | |
| const initializeCanvas = () => { | |
| const canvas = canvasRef.current; | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| 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'); | |
| ctx.fillStyle = '#FFFFFF'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.drawImage( | |
| backgroundImageRef.current, | |
| 0, | |
| 0, | |
| canvas.width, | |
| canvas.height, | |
| ); | |
| }; | |
| // Canvas history functions | |
| const saveCanvasState = () => { | |
| if (!canvasRef.current) return; | |
| const canvas = canvasRef.current; | |
| const dataUrl = canvas.toDataURL(); | |
| const newHistory = history.slice(0, historyIndex + 1); | |
| newHistory.push(dataUrl); | |
| setHistory(newHistory); | |
| setHistoryIndex(newHistory.length - 1); | |
| }; | |
| const restoreCanvasState = (index: number) => { | |
| if (!canvasRef.current || !history[index]) return; | |
| const canvas = canvasRef.current; | |
| const ctx = canvas.getContext('2d'); | |
| const dataUrl = history[index]; | |
| const img = new window.Image(); | |
| img.onload = () => { | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.drawImage(img, 0, 0, canvas.width, canvas.height); | |
| }; | |
| img.src = dataUrl; | |
| }; | |
| const handleUndo = () => { | |
| if (historyIndex > 0) { | |
| const newIndex = historyIndex - 1; | |
| setHistoryIndex(newIndex); | |
| restoreCanvasState(newIndex); | |
| } | |
| }; | |
| const handleRedo = () => { | |
| if (historyIndex < history.length - 1) { | |
| const newIndex = historyIndex + 1; | |
| setHistoryIndex(newIndex); | |
| restoreCanvasState(newIndex); | |
| } | |
| }; | |
| // Get the correct coordinates based on canvas scaling | |
| const getCoordinates = (e: any) => { | |
| const canvas = canvasRef.current!; | |
| const rect = canvas.getBoundingClientRect(); | |
| const scaleX = canvas.width / rect.width; | |
| const scaleY = canvas.height / rect.height; | |
| 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: any) => { | |
| const canvas = canvasRef.current!; | |
| const ctx = canvas.getContext('2d')!; | |
| const {x, y} = getCoordinates(e); | |
| if (e.type === 'touchstart') { | |
| e.preventDefault(); | |
| } | |
| ctx.beginPath(); | |
| ctx.moveTo(x, y); | |
| setIsDrawing(true); | |
| }; | |
| const draw = (e: any) => { | |
| if (!isDrawing) return; | |
| 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 = '#000000'; | |
| ctx.lineTo(x, y); | |
| ctx.stroke(); | |
| }; | |
| const stopDrawing = () => { | |
| if (!isDrawing) return; | |
| setIsDrawing(false); | |
| saveCanvasState(); | |
| }; | |
| const handleClear = () => { | |
| if (mode === 'canvas' && canvasRef.current) { | |
| initializeCanvas(); | |
| const dataUrl = canvasRef.current.toDataURL(); | |
| setHistory([dataUrl]); | |
| setHistoryIndex(0); | |
| } | |
| setGeneratedImage(null); | |
| setMultiImages([]); | |
| backgroundImageRef.current = null; | |
| setPrompt(''); | |
| }; | |
| const processFiles = (files: FileList | null) => { | |
| if (!files) return; | |
| const fileArray = Array.from(files).filter((f) => | |
| f.type.startsWith('image/'), | |
| ); | |
| if (fileArray.length === 0) return; | |
| if (!apiKey) { | |
| setShowApiKeyModal(true); | |
| } | |
| if (mode === 'multi-img-edit') { | |
| const readers = fileArray.map((file) => { | |
| return new Promise<{url: string; type: string}>((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => | |
| resolve({url: reader.result as string, type: file.type}); | |
| reader.onerror = reject; | |
| reader.readAsDataURL(file); | |
| }); | |
| }); | |
| Promise.all(readers).then((newImages) => { | |
| setMultiImages((prev) => [...prev, ...newImages]); | |
| }); | |
| } else { | |
| const file = fileArray[0]; | |
| const reader = new FileReader(); | |
| reader.onload = () => { | |
| setGeneratedImage(reader.result as string); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| }; | |
| const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| processFiles(e.target.files); | |
| e.target.value = ''; | |
| }; | |
| const handleDrop = (e: React.DragEvent<HTMLDivElement>) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| e.currentTarget.classList.remove('border-blue-500'); | |
| processFiles(e.dataTransfer.files); | |
| }; | |
| const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| }; | |
| const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => { | |
| e.currentTarget.classList.add('border-blue-500'); | |
| }; | |
| const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => { | |
| e.currentTarget.classList.remove('border-blue-500'); | |
| }; | |
| const removeImage = (indexToRemove: number) => { | |
| setMultiImages((prev) => | |
| prev.filter((_, index) => index !== indexToRemove), | |
| ); | |
| }; | |
| // *** MODIFIED FUNCTION *** | |
| const handleSubmit = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (!apiKey) { | |
| setShowApiKeyModal(true); | |
| return; | |
| } | |
| setIsLoading(true); | |
| try { | |
| if (mode === 'editor' && !generatedImage) { | |
| setErrorMessage('Please upload an image to edit.'); | |
| setShowErrorModal(true); | |
| return; | |
| } | |
| if (mode === 'multi-img-edit' && multiImages.length === 0) { | |
| setErrorMessage('Please upload at least one image to edit.'); | |
| setShowErrorModal(true); | |
| return; | |
| } | |
| const parts: any[] = []; | |
| // This logic for building the 'parts' array is correct. | |
| if (mode === 'imageGen') { | |
| const tempCanvas = document.createElement('canvas'); | |
| tempCanvas.width = 960; | |
| tempCanvas.height = 540; | |
| const tempCtx = tempCanvas.getContext('2d')!; | |
| tempCtx.fillStyle = '#FFFFFF'; | |
| tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); | |
| tempCtx.fillStyle = '#FEFEFE'; | |
| tempCtx.fillRect(0, 0, 1, 1); | |
| const imageB64 = tempCanvas.toDataURL('image/png').split(',')[1]; | |
| parts.push({inlineData: {data: imageB64, mimeType: 'image/png'}}); | |
| } else if (mode === 'canvas') { | |
| if (!canvasRef.current) return; | |
| const canvas = canvasRef.current; | |
| const imageB64 = canvas.toDataURL('image/png').split(',')[1]; | |
| parts.push({inlineData: {data: imageB64, mimeType: 'image/png'}}); | |
| } else if (mode === 'editor' && generatedImage) { | |
| const mimeType = generatedImage.substring( | |
| generatedImage.indexOf(':') + 1, | |
| generatedImage.indexOf(';'), | |
| ); | |
| const imageB64 = generatedImage.split(',')[1]; | |
| parts.push({inlineData: {data: imageB64, mimeType}}); | |
| } else if (mode === 'multi-img-edit') { | |
| multiImages.forEach((img) => { | |
| parts.push({ | |
| inlineData: {data: img.url.split(',')[1], mimeType: img.type}, | |
| }); | |
| }); | |
| } | |
| parts.push({text: prompt}); | |
| // Construct the request body for the Gemini REST API | |
| const requestBody = { | |
| contents: [{role: 'USER', parts}], | |
| }; | |
| // Define the proxy endpoint | |
| const proxyUrl = `/api-proxy/v1beta/models/gemini-2.5-flash-image-preview:generateContent?key=${apiKey}`; | |
| // Use fetch to send the request to your proxy server | |
| const response = await fetch(proxyUrl, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify(requestBody), | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json(); | |
| throw new Error( | |
| errorData.error?.message || `HTTP error! status: ${response.status}`, | |
| ); | |
| } | |
| const responseData = await response.json(); | |
| // Process the response | |
| const result = {message: '', imageData: null}; | |
| if (responseData.candidates && responseData.candidates.length > 0) { | |
| for (const part of responseData.candidates[0].content.parts) { | |
| if (part.text) { | |
| result.message = part.text; | |
| } else if (part.inlineData) { | |
| result.imageData = part.inlineData.data; | |
| } | |
| } | |
| } else { | |
| throw new Error('Invalid response structure from API.'); | |
| } | |
| if (result.imageData) { | |
| const imageUrl = `data:image/png;base64,${result.imageData}`; | |
| if (mode === 'multi-img-edit') { | |
| setGeneratedImage(imageUrl); | |
| setMultiImages([]); | |
| setMode('editor'); | |
| } else { | |
| setGeneratedImage(imageUrl); | |
| } | |
| } else { | |
| setErrorMessage( | |
| result.message || 'Failed to generate image. Please try again.', | |
| ); | |
| setShowErrorModal(true); | |
| } | |
| } catch (error: any) { | |
| console.error('Error submitting:', error); | |
| setErrorMessage(error.message || 'An unexpected error occurred.'); | |
| setShowErrorModal(true); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| const closeErrorModal = () => { | |
| setShowErrorModal(false); | |
| }; | |
| const handleApiKeySubmit = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| const newApiKey = (e.target as any).apiKey.value; | |
| if (newApiKey) { | |
| setApiKey(newApiKey); | |
| setShowApiKeyModal(false); | |
| } | |
| }; | |
| useEffect(() => { | |
| const canvas = canvasRef.current; | |
| if (!canvas) return; | |
| const preventTouchDefault = (e: TouchEvent) => { | |
| if (isDrawing) { | |
| e.preventDefault(); | |
| } | |
| }; | |
| canvas.addEventListener('touchstart', preventTouchDefault, { | |
| passive: false, | |
| }); | |
| canvas.addEventListener('touchmove', preventTouchDefault, { | |
| passive: false, | |
| }); | |
| return () => { | |
| canvas.removeEventListener('touchstart', preventTouchDefault); | |
| canvas.removeEventListener('touchmove', preventTouchDefault); | |
| }; | |
| }, [isDrawing]); | |
| const baseDisplayClass = | |
| 'w-full sm:h-[60vh] h-[40vh] min-h-[320px] bg-white/90 touch-none flex items-center justify-center p-4 transition-colors'; | |
| return ( | |
| <> | |
| <div className="min-h-screen 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"> | |
| Nano Banana AIO | |
| </h1> | |
| <p className="text-sm sm:text-base text-gray-500 mt-1"> | |
| constructed with the{' '} | |
| <a | |
| className="underline" | |
| href="https://aistudio.google.com/app/apikey" | |
| target="_blank" | |
| rel="noopener noreferrer"> | |
| gemini api | |
| </a>{' '} | |
| by{' '} | |
| <a | |
| className="underline" | |
| href="https://huggingface.co/prithivMLmods" | |
| target="_blank" | |
| rel="noopener noreferrer"> | |
| prithivsakthi-ur | |
| </a> | |
| </p> | |
| </div> | |
| <menu className="flex items-center bg-gray-300 rounded-full p-2 shadow-sm self-start sm:self-auto"> | |
| <div className="flex flex-wrap justify-center items-center bg-gray-200/80 rounded-full p-1 mr-2"> | |
| <div className="relative" ref={dropdownRef}> | |
| <button | |
| onClick={() => setIsDropdownOpen(!isDropdownOpen)} | |
| className={`p-2 sm:px-3 sm:py-1.5 rounded-full text-sm font-medium flex items-center gap-2 transition-colors ${ | |
| mode === 'editor' || mode === 'multi-img-edit' | |
| ? 'bg-white shadow' | |
| : 'text-gray-600 hover:bg-gray-300/50' | |
| }`} | |
| aria-haspopup="true" | |
| aria-expanded={isDropdownOpen}> | |
| {mode === 'multi-img-edit' ? ( | |
| <> | |
| <Library className="w-4 h-4" /> | |
| <span className="hidden sm:inline">Multi-Image</span> | |
| </> | |
| ) : ( | |
| <> | |
| <PictureInPicture className="w-4 h-4" /> | |
| <span className="hidden sm:inline">Editor</span> | |
| </> | |
| )} | |
| <ChevronDown className="w-4 h-4 opacity-70" /> | |
| </button> | |
| {isDropdownOpen && ( | |
| <div className="absolute top-full mt-2 w-48 bg-white rounded-lg shadow-xl z-10 border border-gray-200 py-1"> | |
| <button | |
| onClick={() => { | |
| setMode('editor'); | |
| setIsDropdownOpen(false); | |
| }} | |
| className={`w-full text-left px-3 py-2 text-sm flex items-center gap-3 transition-colors ${ | |
| mode === 'editor' | |
| ? 'bg-gray-100 text-gray-900' | |
| : 'text-gray-700 hover:bg-gray-50' | |
| }`} | |
| aria-pressed={mode === 'editor'}> | |
| <PictureInPicture className="w-4 h-4" /> | |
| <span>Single Image Edit</span> | |
| </button> | |
| <button | |
| onClick={() => { | |
| setMode('multi-img-edit'); | |
| setIsDropdownOpen(false); | |
| }} | |
| className={`w-full text-left px-3 py-2 text-sm flex items-center gap-3 transition-colors ${ | |
| mode === 'multi-img-edit' | |
| ? 'bg-gray-100 text-gray-900' | |
| : 'text-gray-700 hover:bg-gray-50' | |
| }`} | |
| aria-pressed={mode === 'multi-img-edit'}> | |
| <Library className="w-4 h-4" /> | |
| <span>Multi-Image Edit</span> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| <button | |
| onClick={() => setMode('canvas')} | |
| className={`p-2 sm:px-3 sm:py-1.5 rounded-full text-sm font-medium flex items-center gap-2 transition-colors ${ | |
| mode === 'canvas' | |
| ? 'bg-white shadow' | |
| : 'text-gray-600 hover:bg-gray-300/50' | |
| }`} | |
| aria-pressed={mode === 'canvas'}> | |
| <Paintbrush className="w-4 h-4" /> | |
| <span className="hidden sm:inline">Canvas</span> | |
| </button> | |
| <button | |
| onClick={() => setMode('imageGen')} | |
| className={`p-2 sm:px-3 sm:py-1.5 rounded-full text-sm font-medium flex items-center gap-2 transition-colors ${ | |
| mode === 'imageGen' | |
| ? 'bg-white shadow' | |
| : 'text-gray-600 hover:bg-gray-300/50' | |
| }`} | |
| aria-pressed={mode === 'imageGen'}> | |
| <Sparkles className="w-4 h-4" /> | |
| <span className="hidden sm:inline">Image Gen</span> | |
| </button> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={handleClear} | |
| 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> | |
| <div className="w-full mb-6"> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| onChange={handleFileChange} | |
| accept="image/*" | |
| className="hidden" | |
| aria-label="Upload image" | |
| multiple={mode === 'multi-img-edit'} | |
| /> | |
| {mode === 'canvas' ? ( | |
| <div className="relative w-full"> | |
| <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 sm:h-[60vh] h-[40vh] min-h-[320px] bg-white/90 touch-none" | |
| style={{ | |
| cursor: | |
| "url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"%23FF0000\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg>') 12 12, crosshair", | |
| }} | |
| /> | |
| <div className="absolute top-2 right-2 flex gap-2"> | |
| <button | |
| onClick={handleUndo} | |
| disabled={historyIndex <= 0} | |
| className="p-2 bg-white rounded-md shadow disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 transition-colors" | |
| aria-label="Undo"> | |
| <Undo2 className="w-5 h-5" /> | |
| </button> | |
| <button | |
| onClick={handleRedo} | |
| disabled={historyIndex >= history.length - 1} | |
| className="p-2 bg-white rounded-md shadow disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 transition-colors" | |
| aria-label="Redo"> | |
| <Redo2 className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| </div> | |
| ) : mode === 'editor' ? ( | |
| <div | |
| className={`${baseDisplayClass} ${ | |
| generatedImage ? 'border-black' : 'border-gray-400' | |
| } border-2 border-dashed`} | |
| onDrop={handleDrop} | |
| onDragOver={handleDragOver} | |
| onDragEnter={handleDragEnter} | |
| onDragLeave={handleDragLeave}> | |
| {generatedImage ? ( | |
| <img | |
| src={generatedImage} | |
| alt="Current image" | |
| className="max-w-full max-h-full object-contain" | |
| /> | |
| ) : ( | |
| <button | |
| type="button" | |
| onClick={() => fileInputRef.current?.click()} | |
| className="text-center text-gray-500 hover:text-gray-700 p-8 rounded-lg"> | |
| <h3 className="font-semibold text-lg">Upload Image</h3> | |
| <p>Click to upload or drag & drop</p> | |
| </button> | |
| )} | |
| </div> | |
| ) : mode === 'multi-img-edit' ? ( | |
| <div | |
| className={`${baseDisplayClass} ${ | |
| multiImages.length > 0 | |
| ? 'border-black items-start' | |
| : 'border-gray-400' | |
| } border-2 border-dashed flex-col`} | |
| onDrop={handleDrop} | |
| onDragOver={handleDragOver} | |
| onDragEnter={handleDragEnter} | |
| onDragLeave={handleDragLeave}> | |
| {multiImages.length > 0 ? ( | |
| <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 p-4 overflow-y-auto w-full h-full"> | |
| {multiImages.map((image, index) => ( | |
| <div key={index} className="relative group aspect-square"> | |
| <img | |
| src={image.url} | |
| alt={`upload preview ${index + 1}`} | |
| className="w-full h-full object-cover rounded-md" | |
| /> | |
| <button | |
| onClick={() => removeImage(index)} | |
| className="absolute top-1 right-1 bg-black/50 text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity" | |
| aria-label={`Remove image ${index + 1}`}> | |
| <X className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| ))} | |
| <button | |
| type="button" | |
| onClick={() => fileInputRef.current?.click()} | |
| className="flex items-center justify-center border-2 border-dashed border-gray-300 rounded-md text-gray-400 hover:text-gray-600 hover:border-gray-400 transition-colors aspect-square"> | |
| + Add more | |
| </button> | |
| </div> | |
| ) : ( | |
| <button | |
| type="button" | |
| onClick={() => fileInputRef.current?.click()} | |
| className="text-center text-gray-500 hover:text-gray-700 p-8 rounded-lg m-auto"> | |
| <h3 className="font-semibold text-lg"> | |
| Upload one or multiple images | |
| </h3> | |
| <p>Click to upload or drag & drop</p> | |
| </button> | |
| )} | |
| </div> | |
| ) : ( | |
| // Image Gen mode display | |
| <div | |
| className={`relative ${baseDisplayClass} border-2 ${ | |
| generatedImage ? 'border-black' : 'border-gray-400' | |
| }`}> | |
| {generatedImage ? ( | |
| <img | |
| src={generatedImage} | |
| alt="Generated image" | |
| className="max-w-full max-h-full object-contain" | |
| /> | |
| ) : ( | |
| <div className="text-center text-gray-500"> | |
| <h3 className="font-semibold text-lg">Image Generation</h3> | |
| <p>Enter a prompt below to create an image</p> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| {/* Input form */} | |
| <form onSubmit={handleSubmit} className="w-full"> | |
| <div className="relative"> | |
| <input | |
| type="text" | |
| value={prompt} | |
| onChange={(e) => setPrompt(e.target.value)} | |
| placeholder={ | |
| mode === 'imageGen' | |
| ? 'Describe the image you want to create...' | |
| : mode === 'multi-img-edit' | |
| ? 'Describe how to edit or combine the images...' | |
| : '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" | |
| 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> | |
| <p className="font-medium text-gray-600"> | |
| {parseError(errorMessage)} | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| {/* API Key Modal */} | |
| {showApiKeyModal && ( | |
| <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"> | |
| Add Gemini API Key | |
| </h3> | |
| <button | |
| onClick={() => setShowApiKeyModal(false)} | |
| className="text-gray-400 hover:text-gray-500"> | |
| <X className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| <p className="text-gray-600 mb-4"> | |
| Add the API key to process the request. The API key will be | |
| removed if the app page is refreshed or closed. | |
| </p> | |
| <form onSubmit={handleApiKeySubmit}> | |
| <input | |
| type="password" | |
| name="apiKey" | |
| className="w-full p-2 border-2 border-gray-300 rounded-md mb-4" | |
| placeholder="Enter your Gemini API Key" | |
| required | |
| /> | |
| <button | |
| type="submit" | |
| className="w-full bg-black text-white p-2 rounded-md hover:bg-gray-800 transition-colors"> | |
| Submit | |
| </button> | |
| </form> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </> | |
| ); | |
| } |