|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import {GoogleGenAI, Modality} from '@google/genai'; |
|
|
import { |
|
|
ChevronDown, |
|
|
Download, |
|
|
ImageUp, |
|
|
Info, |
|
|
LoaderCircle, |
|
|
Moon, |
|
|
Paintbrush, |
|
|
Redo2, |
|
|
Sparkles, |
|
|
Sun, |
|
|
Trash2, |
|
|
Undo2, |
|
|
X, |
|
|
} from 'lucide-react'; |
|
|
import {useState, useRef, useEffect} from 'react'; |
|
|
|
|
|
|
|
|
|
|
|
const aspectRatios = ['1:1', '16:9', '9:16', '4:3', '3:4']; |
|
|
|
|
|
type Mode = 'text-to-image' | 'image-to-image' | 'draw-to-image'; |
|
|
type Theme = 'light' | 'dark'; |
|
|
|
|
|
function parseError(error: any): string { |
|
|
if (error instanceof Error) { |
|
|
const match = error.message.match(/"message":\s*"(.*?)"/); |
|
|
if (match && match[1]) { |
|
|
return match[1]; |
|
|
} |
|
|
return error.message; |
|
|
} |
|
|
if (typeof error === 'string') { |
|
|
return error; |
|
|
} |
|
|
return 'An unexpected error occurred.'; |
|
|
} |
|
|
|
|
|
export default function Home() { |
|
|
const [mode, setMode] = useState<Mode>('text-to-image'); |
|
|
const [prompt, setPrompt] = useState(''); |
|
|
const [sourceImages, setSourceImages] = useState<string[]>([]); |
|
|
const [resultImages, setResultImages] = useState<string[]>([]); |
|
|
const [selectedImageIndex, setSelectedImageIndex] = useState(0); |
|
|
const [isLoading, setIsLoading] = useState(false); |
|
|
const [errorMessage, setErrorMessage] = useState(''); |
|
|
const [showAdvanced, setShowAdvanced] = useState(true); |
|
|
const [aspectRatio, setAspectRatio] = useState('1:1'); |
|
|
const [downloadType, setDownloadType] = useState<'png' | 'jpeg'>('png'); |
|
|
const [numberOfImages, setNumberOfImages] = useState(1); |
|
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false); |
|
|
const [theme, setTheme] = useState<Theme>('light'); |
|
|
const dropdownRef = useRef<HTMLDivElement>(null); |
|
|
const fileInputRef = useRef<HTMLInputElement>(null); |
|
|
|
|
|
|
|
|
const [apiKey, setApiKey] = useState(''); |
|
|
const [showApiKeyModal, setShowApiKeyModal] = useState(false); |
|
|
const [tempApiKey, setTempApiKey] = useState(''); |
|
|
const [isSubmitting, setIsSubmitting] = useState(false); |
|
|
|
|
|
|
|
|
|
|
|
const canvasRef = useRef<HTMLCanvasElement>(null); |
|
|
const [isDrawing, setIsDrawing] = useState(false); |
|
|
const [canvasHistory, setCanvasHistory] = useState<string[]>([]); |
|
|
const [historyIndex, setHistoryIndex] = useState(-1); |
|
|
|
|
|
useEffect(() => { |
|
|
document.documentElement.setAttribute('data-theme', theme); |
|
|
}, [theme]); |
|
|
|
|
|
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); |
|
|
}; |
|
|
}, []); |
|
|
|
|
|
const initCanvas = () => { |
|
|
const canvas = canvasRef.current; |
|
|
if (canvas) { |
|
|
const ctx = canvas.getContext('2d'); |
|
|
if (ctx) { |
|
|
ctx.fillStyle = '#FFFFFF'; |
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height); |
|
|
const dataUrl = canvas.toDataURL(); |
|
|
setCanvasHistory([dataUrl]); |
|
|
setHistoryIndex(0); |
|
|
} |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (mode === 'draw-to-image') { |
|
|
|
|
|
setTimeout(initCanvas, 50); |
|
|
} |
|
|
}, [mode]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (mode === 'draw-to-image' && resultImages.length > 0 && canvasRef.current) { |
|
|
const canvas = canvasRef.current; |
|
|
const ctx = canvas.getContext('2d'); |
|
|
const img = new Image(); |
|
|
img.onload = () => { |
|
|
if (ctx) { |
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height); |
|
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height); |
|
|
saveCanvasState(); |
|
|
} |
|
|
}; |
|
|
img.src = resultImages[0]; |
|
|
} |
|
|
}, [resultImages]); |
|
|
|
|
|
|
|
|
const toggleTheme = () => { |
|
|
setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light')); |
|
|
}; |
|
|
|
|
|
const handleModeChange = (newMode: Mode) => { |
|
|
if (mode !== newMode) { |
|
|
setMode(newMode); |
|
|
setResultImages([]); |
|
|
setSelectedImageIndex(0); |
|
|
setErrorMessage(''); |
|
|
setPrompt(''); |
|
|
setSourceImages([]); |
|
|
setNumberOfImages(1); |
|
|
} |
|
|
setIsDropdownOpen(false); |
|
|
}; |
|
|
|
|
|
const handleClear = () => { |
|
|
setPrompt(''); |
|
|
setSourceImages([]); |
|
|
setResultImages([]); |
|
|
setSelectedImageIndex(0); |
|
|
setErrorMessage(''); |
|
|
setShowAdvanced(true); |
|
|
setAspectRatio('1:1'); |
|
|
setDownloadType('png'); |
|
|
setNumberOfImages(1); |
|
|
if (mode === 'draw-to-image') { |
|
|
initCanvas(); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
const saveCanvasState = () => { |
|
|
if (!canvasRef.current) return; |
|
|
const canvas = canvasRef.current; |
|
|
const dataUrl = canvas.toDataURL(); |
|
|
const newHistory = canvasHistory.slice(0, historyIndex + 1); |
|
|
newHistory.push(dataUrl); |
|
|
setCanvasHistory(newHistory); |
|
|
setHistoryIndex(newHistory.length - 1); |
|
|
}; |
|
|
|
|
|
const restoreCanvasState = (index: number) => { |
|
|
if (!canvasRef.current || !canvasHistory[index]) return; |
|
|
const canvas = canvasRef.current; |
|
|
const ctx = canvas.getContext('2d'); |
|
|
const dataUrl = canvasHistory[index]; |
|
|
const img = new window.Image(); |
|
|
img.onload = () => { |
|
|
if(ctx) { |
|
|
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 < canvasHistory.length - 1) { |
|
|
const newIndex = historyIndex + 1; |
|
|
setHistoryIndex(newIndex); |
|
|
restoreCanvasState(newIndex); |
|
|
} |
|
|
}; |
|
|
|
|
|
const getCoordinates = (e: React.MouseEvent | React.TouchEvent) => { |
|
|
const canvas = canvasRef.current; |
|
|
if(!canvas) return { x: 0, y: 0 }; |
|
|
const rect = canvas.getBoundingClientRect(); |
|
|
const scaleX = canvas.width / rect.width; |
|
|
const scaleY = canvas.height / rect.height; |
|
|
|
|
|
let clientX, clientY; |
|
|
if ('touches' in e.nativeEvent) { |
|
|
clientX = e.nativeEvent.touches[0].clientX; |
|
|
clientY = e.nativeEvent.touches[0].clientY; |
|
|
} else { |
|
|
clientX = e.nativeEvent.clientX; |
|
|
clientY = e.nativeEvent.clientY; |
|
|
} |
|
|
|
|
|
return { |
|
|
x: (clientX - rect.left) * scaleX, |
|
|
y: (clientY - rect.top) * scaleY, |
|
|
}; |
|
|
}; |
|
|
|
|
|
const startDrawing = (e: React.MouseEvent | React.TouchEvent) => { |
|
|
if ('touches' in e.nativeEvent) e.preventDefault(); |
|
|
const canvas = canvasRef.current; |
|
|
if(!canvas) return; |
|
|
const ctx = canvas.getContext('2d'); |
|
|
if(!ctx) return; |
|
|
const {x, y} = getCoordinates(e); |
|
|
ctx.beginPath(); |
|
|
ctx.moveTo(x, y); |
|
|
setIsDrawing(true); |
|
|
}; |
|
|
|
|
|
const draw = (e: React.MouseEvent | React.TouchEvent) => { |
|
|
if (!isDrawing) return; |
|
|
if ('touches' in e.nativeEvent) e.preventDefault(); |
|
|
const canvas = canvasRef.current; |
|
|
if(!canvas) return; |
|
|
const ctx = canvas.getContext('2d'); |
|
|
if(!ctx) return; |
|
|
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(); |
|
|
}; |
|
|
|
|
|
useEffect(() => { |
|
|
const canvas = canvasRef.current; |
|
|
if (!canvas) return; |
|
|
|
|
|
const preventDefault = (e: TouchEvent) => { |
|
|
if (isDrawing) { |
|
|
e.preventDefault(); |
|
|
} |
|
|
}; |
|
|
|
|
|
canvas.addEventListener('touchstart', preventDefault, { passive: false }); |
|
|
canvas.addEventListener('touchmove', preventDefault, { passive: false }); |
|
|
|
|
|
return () => { |
|
|
canvas.removeEventListener('touchstart', preventDefault); |
|
|
canvas.removeEventListener('touchmove', preventDefault); |
|
|
}; |
|
|
}, [isDrawing]); |
|
|
|
|
|
|
|
|
const processFiles = (files: FileList) => { |
|
|
if (!files || files.length === 0) return; |
|
|
const imageFiles = Array.from(files).filter((file) => |
|
|
file.type.startsWith('image/'), |
|
|
); |
|
|
if (imageFiles.length === 0) return; |
|
|
|
|
|
const readers = imageFiles.map((file) => { |
|
|
return new Promise<string>((resolve, reject) => { |
|
|
const reader = new FileReader(); |
|
|
reader.onload = () => resolve(reader.result as string); |
|
|
reader.onerror = (error) => reject(error); |
|
|
reader.readAsDataURL(file); |
|
|
}); |
|
|
}); |
|
|
|
|
|
Promise.all(readers) |
|
|
.then((newImages) => { |
|
|
setSourceImages((prev) => [...prev, ...newImages]); |
|
|
setResultImages([]); |
|
|
setSelectedImageIndex(0); |
|
|
}) |
|
|
.catch(() => { |
|
|
setErrorMessage('Failed to read one or more files.'); |
|
|
}); |
|
|
}; |
|
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
|
|
if (e.target.files) { |
|
|
processFiles(e.target.files); |
|
|
} |
|
|
e.target.value = ''; |
|
|
}; |
|
|
|
|
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => { |
|
|
e.preventDefault(); |
|
|
e.stopPropagation(); |
|
|
e.currentTarget.classList.remove('dragover'); |
|
|
if (e.dataTransfer.files) { |
|
|
processFiles(e.dataTransfer.files); |
|
|
} |
|
|
}; |
|
|
|
|
|
const removeImage = (indexToRemove: number) => { |
|
|
setSourceImages((prev) => |
|
|
prev.filter((_, index) => index !== indexToRemove), |
|
|
); |
|
|
}; |
|
|
|
|
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => { |
|
|
e.preventDefault(); |
|
|
e.stopPropagation(); |
|
|
}; |
|
|
|
|
|
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => { |
|
|
e.preventDefault(); |
|
|
e.stopPropagation(); |
|
|
e.currentTarget.classList.add('dragover'); |
|
|
}; |
|
|
|
|
|
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => { |
|
|
e.preventDefault(); |
|
|
e.stopPropagation(); |
|
|
e.currentTarget.classList.remove('dragover'); |
|
|
}; |
|
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => { |
|
|
e.preventDefault(); |
|
|
if (!apiKey) { |
|
|
setShowApiKeyModal(true); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (isSubmitting) return; |
|
|
setIsSubmitting(true); |
|
|
await generateOrEditImage(); |
|
|
setIsSubmitting(false); |
|
|
|
|
|
} |
|
|
|
|
|
const handleApiKeySubmit = async (e: React.FormEvent) => { |
|
|
e.preventDefault(); |
|
|
if (!tempApiKey) { |
|
|
setErrorMessage("Please enter an API key."); |
|
|
return; |
|
|
} |
|
|
setApiKey(tempApiKey); |
|
|
setShowApiKeyModal(false); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
if (isSubmitting) return; |
|
|
setIsSubmitting(true); |
|
|
generateOrEditImage(tempApiKey).finally(() => setIsSubmitting(false)); |
|
|
}, 0); |
|
|
}; |
|
|
|
|
|
|
|
|
const generateOrEditImage = async (currentApiKey?: string) => { |
|
|
const keyToUse = currentApiKey || apiKey; |
|
|
if (!keyToUse) { |
|
|
setShowApiKeyModal(true); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (!prompt) { |
|
|
setErrorMessage('Please enter a prompt to continue.'); |
|
|
return; |
|
|
} |
|
|
if (mode === 'image-to-image' && sourceImages.length === 0) { |
|
|
setErrorMessage('Please upload at least one source image for editing.'); |
|
|
return; |
|
|
} |
|
|
|
|
|
setIsLoading(true); |
|
|
setResultImages([]); |
|
|
setSelectedImageIndex(0); |
|
|
setErrorMessage(''); |
|
|
|
|
|
try { |
|
|
const ai = new GoogleGenAI({apiKey: keyToUse}); |
|
|
if ((mode === 'image-to-image' && sourceImages.length > 0) || mode === 'draw-to-image') { |
|
|
|
|
|
const parts = []; |
|
|
if (mode === 'image-to-image') { |
|
|
const imageParts = sourceImages.map((imgData) => { |
|
|
const mimeType = imgData.substring( |
|
|
imgData.indexOf(':') + 1, |
|
|
imgData.indexOf(';'), |
|
|
); |
|
|
const imageB64 = imgData.split(',')[1]; |
|
|
return {inlineData: {data: imageB64, mimeType}}; |
|
|
}); |
|
|
parts.push(...imageParts); |
|
|
} else if (mode === 'draw-to-image' && canvasRef.current) { |
|
|
const imageB64 = canvasRef.current.toDataURL('image/png').split(',')[1]; |
|
|
parts.push({inlineData: {data: imageB64, mimeType: 'image/png'}}); |
|
|
} |
|
|
|
|
|
const textPart = {text: prompt}; |
|
|
parts.push(textPart); |
|
|
|
|
|
const response = await ai.models.generateContent({ |
|
|
model: 'gemini-1.5-flash-latest', |
|
|
contents: {parts}, |
|
|
config: { |
|
|
responseMimeType: 'application/json', |
|
|
}, |
|
|
}); |
|
|
|
|
|
let foundImage = false; |
|
|
const parsedResponse = JSON.parse(response.text); |
|
|
|
|
|
if(parsedResponse.candidates && parsedResponse.candidates[0].content.parts) { |
|
|
for (const part of parsedResponse.candidates[0].content.parts) { |
|
|
if (part.inlineData) { |
|
|
const imageUrl = `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`; |
|
|
setResultImages([imageUrl]); |
|
|
foundImage = true; |
|
|
break; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
if (!foundImage) { |
|
|
const textMessage = parsedResponse.text; |
|
|
setErrorMessage( |
|
|
textMessage || |
|
|
'The model did not return an image. Please try a different prompt.', |
|
|
); |
|
|
} |
|
|
} else { |
|
|
const response = await ai.models.generateImages({ |
|
|
model: 'imagen-3.0-generate-001', |
|
|
prompt: prompt, |
|
|
config: { |
|
|
numberOfImages: numberOfImages, |
|
|
aspectRatio: aspectRatio as any, |
|
|
outputMimeType: |
|
|
`image/${downloadType}` as 'image/png' | 'image/jpeg', |
|
|
}, |
|
|
}); |
|
|
|
|
|
if (response.generatedImages && response.generatedImages.length > 0) { |
|
|
const imageUrls = response.generatedImages.map((img) => { |
|
|
const base64Image = img.image.imageBytes; |
|
|
return `data:image/${downloadType};base64,${base64Image}`; |
|
|
}); |
|
|
setResultImages(imageUrls); |
|
|
} else { |
|
|
setErrorMessage( |
|
|
'The model did not return an image. Please try again.', |
|
|
); |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Error during API call:', error); |
|
|
setErrorMessage(parseError(error)); |
|
|
} finally { |
|
|
setIsLoading(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleDownload = async (imageUrl: string) => { |
|
|
const targetMimeType = `image/${downloadType}`; |
|
|
const targetExtension = downloadType; |
|
|
const sourceMimeType = imageUrl.match(/data:(image\/.*?);/)?.[1]; |
|
|
|
|
|
let finalImageUrl = imageUrl; |
|
|
|
|
|
if (sourceMimeType && sourceMimeType !== targetMimeType) { |
|
|
try { |
|
|
finalImageUrl = await new Promise((resolve, reject) => { |
|
|
const img = new Image(); |
|
|
img.onload = () => { |
|
|
const canvas = document.createElement('canvas'); |
|
|
canvas.width = img.width; |
|
|
canvas.height = img.height; |
|
|
const ctx = canvas.getContext('2d'); |
|
|
if (!ctx) { |
|
|
reject(new Error('Could not get canvas context')); |
|
|
return; |
|
|
} |
|
|
if (targetMimeType === 'image/jpeg') { |
|
|
ctx.fillStyle = '#FFFFFF'; |
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height); |
|
|
} |
|
|
ctx.drawImage(img, 0, 0); |
|
|
resolve(canvas.toDataURL(targetMimeType, 0.9)); |
|
|
}; |
|
|
img.onerror = () => { |
|
|
reject(new Error('Failed to load image for conversion.')); |
|
|
}; |
|
|
img.src = imageUrl; |
|
|
}); |
|
|
} catch (error) { |
|
|
setErrorMessage( |
|
|
error instanceof Error ? error.message : 'Image conversion failed.', |
|
|
); |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
const link = document.createElement('a'); |
|
|
link.href = finalImageUrl; |
|
|
link.download = `gemini-studio-image.${targetExtension}`; |
|
|
document.body.appendChild(link); |
|
|
link.click(); |
|
|
document.body.removeChild(link); |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="app-container"> |
|
|
<header className="app-header"> |
|
|
<div> |
|
|
<h1>Gemini-Image-Studio</h1> |
|
|
<p>State-of-the-art image generation and editing model.</p> |
|
|
</div> |
|
|
<button |
|
|
onClick={toggleTheme} |
|
|
className="button theme-toggle" |
|
|
aria-label="Toggle theme"> |
|
|
{theme === 'light' ? ( |
|
|
<Moon className="w-6 h-6" /> |
|
|
) : ( |
|
|
<Sun className="w-6 h-6" /> |
|
|
)} |
|
|
</button> |
|
|
</header> |
|
|
<main className="app-main"> |
|
|
<div className="main-grid"> |
|
|
{/* Input Card */} |
|
|
<div className="card"> |
|
|
<div className="head"> |
|
|
<span>INPUT</span> |
|
|
<div |
|
|
className="relative inline-block text-left" |
|
|
ref={dropdownRef}> |
|
|
<button |
|
|
type="button" |
|
|
className="button" |
|
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}> |
|
|
{mode === 'text-to-image' |
|
|
? 'Text-to-Image' |
|
|
: mode === 'image-to-image' |
|
|
? 'Image-to-Image' |
|
|
: 'Draw-to-Image'} |
|
|
<ChevronDown className="-mr-1 h-5 w-5" /> |
|
|
</button> |
|
|
{isDropdownOpen && ( |
|
|
<div className="dropdown-panel"> |
|
|
<div |
|
|
onClick={() => handleModeChange('text-to-image')} |
|
|
className={`dropdown-item ${ |
|
|
mode === 'text-to-image' ? 'active' : '' |
|
|
}`} |
|
|
role="menuitem"> |
|
|
Text-to-Image |
|
|
</div> |
|
|
<div |
|
|
onClick={() => handleModeChange('image-to-image')} |
|
|
className={`dropdown-item ${ |
|
|
mode === 'image-to-image' ? 'active' : '' |
|
|
}`} |
|
|
role="menuitem"> |
|
|
Image-to-Image |
|
|
</div> |
|
|
<div |
|
|
onClick={() => handleModeChange('draw-to-image')} |
|
|
className={`dropdown-item ${ |
|
|
mode === 'draw-to-image' ? 'active' : '' |
|
|
}`} |
|
|
role="menuitem"> |
|
|
Draw-to-Image |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
<div className="content"> |
|
|
<form onSubmit={handleSubmit} className="form-content"> |
|
|
{mode === 'image-to-image' && ( |
|
|
<div className="form-group"> |
|
|
<label>Source Image(s)</label> |
|
|
<input |
|
|
ref={fileInputRef} |
|
|
id="file-upload" |
|
|
type="file" |
|
|
onChange={handleFileChange} |
|
|
accept="image/*" |
|
|
className="hidden" |
|
|
multiple |
|
|
/> |
|
|
<div |
|
|
className="uploader" |
|
|
onDrop={handleDrop} |
|
|
onDragOver={handleDragOver} |
|
|
onDragEnter={handleDragEnter} |
|
|
onDragLeave={handleDragLeave}> |
|
|
{sourceImages.length > 0 ? ( |
|
|
<div className="image-grid"> |
|
|
{sourceImages.map((image, index) => ( |
|
|
<div |
|
|
key={index} |
|
|
className="relative group aspect-square"> |
|
|
<img src={image} alt={`Source ${index + 1}`} /> |
|
|
<button |
|
|
type="button" |
|
|
onClick={() => removeImage(index)} |
|
|
className="image-remove-button" |
|
|
aria-label={`Remove image ${index + 1}`}> |
|
|
<X className="w-4 h-4" /> |
|
|
</button> |
|
|
</div> |
|
|
))} |
|
|
<button |
|
|
type="button" |
|
|
onClick={() => fileInputRef.current?.click()} |
|
|
className="uploader-add-button"> |
|
|
+ Add |
|
|
</button> |
|
|
</div> |
|
|
) : ( |
|
|
<button |
|
|
type="button" |
|
|
onClick={() => fileInputRef.current?.click()} |
|
|
className="uploader-placeholder"> |
|
|
<ImageUp className="w-8 h-8" /> |
|
|
<span>Click or Drag & Drop</span> |
|
|
</button> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{mode === 'draw-to-image' && ( |
|
|
<div className="form-group"> |
|
|
<label>Canvas</label> |
|
|
<div className="canvas-container"> |
|
|
<canvas |
|
|
ref={canvasRef} |
|
|
width={960} |
|
|
height={540} |
|
|
onMouseDown={startDrawing} |
|
|
onMouseMove={draw} |
|
|
onMouseUp={stopDrawing} |
|
|
onMouseLeave={stopDrawing} |
|
|
onTouchStart={startDrawing} |
|
|
onTouchMove={draw} |
|
|
onTouchEnd={stopDrawing} |
|
|
/> |
|
|
<div className="canvas-controls"> |
|
|
<button type="button" onClick={handleUndo} disabled={historyIndex <= 0} aria-label="Undo"> |
|
|
<Undo2 className="w-5 h-5" /> |
|
|
</button> |
|
|
<button type="button" onClick={handleRedo} disabled={historyIndex >= canvasHistory.length - 1} aria-label="Redo"> |
|
|
<Redo2 className="w-5 h-5" /> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
|
|
|
<div className="form-group"> |
|
|
<div className="label-with-info"> |
|
|
<label htmlFor="prompt">Prompt</label> |
|
|
{mode === 'text-to-image' && ( |
|
|
<div className="info-tooltip-container"> |
|
|
<Info className="w-4 h-4 info-icon" /> |
|
|
<span className="info-tooltip"> |
|
|
The model used for this mode is <code>imagen-3.0-generate-001</code>. |
|
|
</span> |
|
|
</div> |
|
|
)} |
|
|
{(mode === 'image-to-image' || mode === 'draw-to-image') && ( |
|
|
<div className="info-tooltip-container"> |
|
|
<Info className="w-4 h-4 info-icon" /> |
|
|
<span className="info-tooltip"> |
|
|
The model used for image editing is <code>gemini-1.5-flash-latest</code>. |
|
|
</span> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
<textarea |
|
|
id="prompt" |
|
|
value={prompt} |
|
|
onChange={(e) => setPrompt(e.target.value)} |
|
|
placeholder={ |
|
|
mode === 'image-to-image' |
|
|
? 'Describe how to edit the image(s)...' |
|
|
: mode === 'draw-to-image' |
|
|
? 'Describe the image you want to create from your drawing...' |
|
|
: 'A photorealistic cat astronaut on Mars...' |
|
|
} |
|
|
className="input" |
|
|
required |
|
|
/> |
|
|
</div> |
|
|
|
|
|
<div className="form-group"> |
|
|
<button |
|
|
type="button" |
|
|
onClick={() => setShowAdvanced(!showAdvanced)} |
|
|
className="advanced-toggle"> |
|
|
<span>Advanced Settings</span> |
|
|
<ChevronDown |
|
|
className={`w-5 h-5 transition-transform ${ |
|
|
showAdvanced ? 'transform rotate-180' : '' |
|
|
}`} |
|
|
/> |
|
|
</button> |
|
|
{showAdvanced && ( |
|
|
<div className="advanced-panel"> |
|
|
{mode === 'text-to-image' && ( |
|
|
<> |
|
|
<div className="form-group"> |
|
|
<div className="label-with-value"> |
|
|
<label htmlFor="number-of-images"> |
|
|
Number of Images |
|
|
</label> |
|
|
<span>{numberOfImages}</span> |
|
|
</div> |
|
|
<input |
|
|
id="number-of-images" |
|
|
type="range" |
|
|
value={numberOfImages} |
|
|
onChange={(e) => |
|
|
setNumberOfImages(parseInt(e.target.value, 10)) |
|
|
} |
|
|
min="1" |
|
|
max="4" |
|
|
step="1" |
|
|
className="slider" |
|
|
/> |
|
|
</div> |
|
|
<div className="form-group"> |
|
|
<label htmlFor="aspect-ratio">Aspect Ratio</label> |
|
|
<select |
|
|
id="aspect-ratio" |
|
|
value={aspectRatio} |
|
|
onChange={(e) => setAspectRatio(e.target.value)} |
|
|
className="input"> |
|
|
{aspectRatios.map((ar) => ( |
|
|
<option key={ar} value={ar}> |
|
|
{ar} |
|
|
</option> |
|
|
))} |
|
|
</select> |
|
|
</div> |
|
|
</> |
|
|
)} |
|
|
<div className="form-group"> |
|
|
<label htmlFor="download-type">Download Format</label> |
|
|
<select |
|
|
id="download-type" |
|
|
value={downloadType} |
|
|
onChange={(e) => |
|
|
setDownloadType(e.target.value as 'png' | 'jpeg') |
|
|
} |
|
|
className="input"> |
|
|
<option value="png">PNG</option> |
|
|
<option value="jpeg">JPEG</option> |
|
|
</select> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
|
|
|
<div className="form-actions"> |
|
|
<button |
|
|
type="submit" |
|
|
disabled={isLoading} |
|
|
className="button primary"> |
|
|
{isLoading ? ( |
|
|
<> |
|
|
<LoaderCircle className="w-5 h-5 animate-spin" /> |
|
|
<span> |
|
|
{mode === 'image-to-image' |
|
|
? 'Editing...' |
|
|
: 'Generating...'} |
|
|
</span> |
|
|
</> |
|
|
) : ( |
|
|
<> |
|
|
{mode === 'text-to-image' && 'Generate Image'} |
|
|
{mode === 'image-to-image' && 'Edit Image'} |
|
|
{mode === 'draw-to-image' && 'Generate from Drawing'} |
|
|
</> |
|
|
)} |
|
|
</button> |
|
|
<button |
|
|
type="button" |
|
|
onClick={handleClear} |
|
|
className="button secondary" |
|
|
aria-label="Clear inputs"> |
|
|
<Trash2 className="w-5 h-5" /> |
|
|
</button> |
|
|
</div> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{} |
|
|
<div className="card"> |
|
|
<div className="head"> |
|
|
<span>RESULT</span> |
|
|
</div> |
|
|
<div className="content"> |
|
|
<div className="result-area"> |
|
|
{isLoading ? ( |
|
|
<div className="result-placeholder"> |
|
|
<LoaderCircle className="w-10 h-10 animate-spin" /> |
|
|
<p> |
|
|
{mode === 'image-to-image' || mode === 'draw-to-image' |
|
|
? 'Processing your image...' |
|
|
: `Generating ${numberOfImages} image(s)...`} |
|
|
</p> |
|
|
</div> |
|
|
) : resultImages.length > 0 ? ( |
|
|
<div className="showcase-container"> |
|
|
<div className="main-image-wrapper group"> |
|
|
<img |
|
|
src={resultImages[selectedImageIndex]} |
|
|
alt={`Generated result ${selectedImageIndex + 1}`} |
|
|
/> |
|
|
<button |
|
|
onClick={() => |
|
|
handleDownload(resultImages[selectedImageIndex]) |
|
|
} |
|
|
className="download-button" |
|
|
aria-label="Download image"> |
|
|
<Download className="w-5 h-5" /> |
|
|
</button> |
|
|
</div> |
|
|
{resultImages.length > 1 && ( |
|
|
<div className="thumbnail-container"> |
|
|
{resultImages.map((image, index) => ( |
|
|
<img |
|
|
key={index} |
|
|
src={image} |
|
|
alt={`Thumbnail ${index + 1}`} |
|
|
className={`thumbnail-image ${ |
|
|
index === selectedImageIndex ? 'active' : '' |
|
|
}`} |
|
|
onClick={() => setSelectedImageIndex(index)} |
|
|
/> |
|
|
))} |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
) : ( |
|
|
<div className="result-placeholder"> |
|
|
{mode === 'draw-to-image' ? <Paintbrush className="w-10 h-10" /> : <Sparkles className="w-10 h-10" />} |
|
|
<h3> |
|
|
{mode === 'draw-to-image' |
|
|
? "Start drawing and see your ideas come to life" |
|
|
: "Your result will appear here" |
|
|
} |
|
|
</h3> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</main> |
|
|
|
|
|
{errorMessage && ( |
|
|
<div className="modal-backdrop"> |
|
|
<div className="card modal-card"> |
|
|
<div className="head"> |
|
|
<span>REQUEST FAILED</span> |
|
|
<button |
|
|
onClick={() => setErrorMessage('')} |
|
|
className="modal-close-button"> |
|
|
<X className="w-5 h-5" /> |
|
|
</button> |
|
|
</div> |
|
|
<div className="content"> |
|
|
<p>{errorMessage}</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{showApiKeyModal && ( |
|
|
<div className="modal-backdrop"> |
|
|
<div className="card modal-card api-key-modal"> |
|
|
<div className="head"> |
|
|
<span>Add Your Gemini API Key</span> |
|
|
<button |
|
|
onClick={() => setShowApiKeyModal(false)} |
|
|
className="modal-close-button"> |
|
|
<X className="w-5 h-5" /> |
|
|
</button> |
|
|
</div> |
|
|
<div className="content"> |
|
|
<p className="api-key-info"> |
|
|
Your API key is only stored for this session and will be lost when you reload or exit the page. It is not shared or exposed anywhere. |
|
|
</p> |
|
|
<form onSubmit={handleApiKeySubmit} className="api-key-form"> |
|
|
<input |
|
|
type="password" |
|
|
value={tempApiKey} |
|
|
onChange={(e) => setTempApiKey(e.target.value)} |
|
|
className="input" |
|
|
placeholder="Enter your Gemini API Key" |
|
|
required |
|
|
/> |
|
|
<button type="submit" className="button primary" disabled={isLoading}> |
|
|
{isLoading ? ( |
|
|
<LoaderCircle className="w-5 h-5 animate-spin" /> |
|
|
) : ( |
|
|
"Submit & Run" |
|
|
)} |
|
|
</button> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
); |
|
|
} |