prithivMLmods commited on
Commit
75e5112
·
verified ·
1 Parent(s): cbdb285

files [initial-commit]

Browse files
Files changed (9) hide show
  1. Dockerfile +33 -0
  2. Home.tsx +712 -0
  3. index.css +223 -0
  4. index.html +15 -0
  5. index.tsx +11 -0
  6. metadata.json +5 -0
  7. package.json +24 -0
  8. tsconfig.json +29 -0
  9. vite.config.ts +23 -0
Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 1: Build the frontend, and install server dependencies
2
+ FROM node:22 AS builder
3
+
4
+ WORKDIR /app
5
+
6
+ # Copy all files from the current directory
7
+ COPY . ./
8
+ RUN echo "API_KEY=PLACEHOLDER" > ./.env
9
+ RUN echo "GEMINI_API_KEY=PLACEHOLDER" >> ./.env
10
+
11
+ # Install server dependencies
12
+ WORKDIR /app/server
13
+ RUN npm install
14
+
15
+ # Install dependencies and build the frontend
16
+ WORKDIR /app
17
+ RUN mkdir dist
18
+ RUN bash -c 'if [ -f package.json ]; then npm install && npm run build; fi'
19
+
20
+
21
+ # Stage 2: Build the final server image
22
+ FROM node:22
23
+
24
+ WORKDIR /app
25
+
26
+ #Copy server files
27
+ COPY --from=builder /app/server .
28
+ # Copy built frontend assets from the builder stage
29
+ COPY --from=builder /app/dist ./dist
30
+
31
+ EXPOSE 3000
32
+
33
+ CMD ["node", "server.js"]
Home.tsx ADDED
@@ -0,0 +1,712 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ /* tslint:disable */
6
+ import {
7
+ GoogleGenAI,
8
+ GenerateContentResponse,
9
+ } from '@google/genai';
10
+ import {
11
+ ArrowUp,
12
+ Brush,
13
+ Cpu,
14
+ Eraser,
15
+ Info,
16
+ LoaderCircle,
17
+ Moon,
18
+ Palette,
19
+ Redo2,
20
+ Sun,
21
+ Trash2,
22
+ Undo2,
23
+ X,
24
+ } from 'lucide-react';
25
+ import {useEffect, useRef, useState} from 'react';
26
+
27
+ export default function Home() {
28
+ const canvasRef = useRef<HTMLCanvasElement>(null);
29
+ const backgroundImageRef = useRef<HTMLImageElement | null>(null);
30
+ const [isDrawing, setIsDrawing] = useState(false);
31
+ const [prompt, setPrompt] = useState('');
32
+ const [apiKey, setApiKey] = useState('');
33
+ const [generatedImage, setGeneratedImage] = useState<string | null>(null);
34
+ const [isLoading, setIsLoading] = useState(false);
35
+ const [showErrorModal, setShowErrorModal] = useState(false);
36
+ const [showInfoModal, setShowInfoModal] = useState(false);
37
+ const [errorMessage, setErrorMessage] = useState('');
38
+ const [errorTitle, setErrorTitle] = useState('Failed to generate');
39
+
40
+ // Brush settings
41
+ const [brushSize, setBrushSize] = useState(5);
42
+ const [showBrushMenu, setShowBrushMenu] = useState(false);
43
+ const [brushColor, setBrushColor] = useState('#000000');
44
+ const [showColorMenu, setShowColorMenu] = useState(false);
45
+ const [isErasing, setIsErasing] = useState(false);
46
+
47
+ // Model settings
48
+ const [selectedModel, setSelectedModel] = useState('gemini-2.5-flash-image');
49
+ const [showModelMenu, setShowModelMenu] = useState(false);
50
+
51
+ // Theme settings
52
+ const [isDarkMode, setIsDarkMode] = useState(false);
53
+
54
+ // State for canvas history
55
+ const [history, setHistory] = useState<string[]>([]);
56
+ const [historyIndex, setHistoryIndex] = useState(-1);
57
+
58
+ // Toggle Dark Mode
59
+ useEffect(() => {
60
+ if (isDarkMode) {
61
+ document.documentElement.setAttribute('data-theme', 'dark');
62
+ } else {
63
+ document.documentElement.removeAttribute('data-theme');
64
+ }
65
+ }, [isDarkMode]);
66
+
67
+ const toggleDarkMode = () => {
68
+ setIsDarkMode(!isDarkMode);
69
+ };
70
+
71
+ // When switching to canvas mode, initialize it and its history
72
+ useEffect(() => {
73
+ if (canvasRef.current) {
74
+ const canvas = canvasRef.current;
75
+ const ctx = canvas.getContext('2d');
76
+ ctx.fillStyle = '#FFFFFF';
77
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
78
+
79
+ // If an image already exists from another mode, draw it.
80
+ if (generatedImage) {
81
+ const img = new window.Image();
82
+ img.onload = () => {
83
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
84
+ // Save this as the initial state for this session
85
+ const dataUrl = canvas.toDataURL();
86
+ setHistory([dataUrl]);
87
+ setHistoryIndex(0);
88
+ };
89
+ img.src = generatedImage;
90
+ } else {
91
+ // Otherwise, save the blank state as initial
92
+ const dataUrl = canvas.toDataURL();
93
+ setHistory([dataUrl]);
94
+ setHistoryIndex(0);
95
+ }
96
+ }
97
+ }, []); // Rerun if the key is selected to init canvas
98
+
99
+ // Load background image when generatedImage changes
100
+ useEffect(() => {
101
+ if (generatedImage && canvasRef.current) {
102
+ const img = new window.Image();
103
+ img.onload = () => {
104
+ backgroundImageRef.current = img;
105
+ drawImageToCanvas();
106
+ // A small timeout to let the draw happen before saving
107
+ setTimeout(saveCanvasState, 50);
108
+ };
109
+ img.src = generatedImage;
110
+ }
111
+ }, [generatedImage]);
112
+
113
+ // Initialize canvas with white background
114
+ const initializeCanvas = () => {
115
+ const canvas = canvasRef.current;
116
+ if (!canvas) return;
117
+ const ctx = canvas.getContext('2d');
118
+ ctx.fillStyle = '#FFFFFF';
119
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
120
+ };
121
+
122
+ // Draw the background image to the canvas
123
+ const drawImageToCanvas = () => {
124
+ if (!canvasRef.current || !backgroundImageRef.current) return;
125
+
126
+ const canvas = canvasRef.current;
127
+ const ctx = canvas.getContext('2d');
128
+ ctx.fillStyle = '#FFFFFF';
129
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
130
+ ctx.drawImage(
131
+ backgroundImageRef.current,
132
+ 0,
133
+ 0,
134
+ canvas.width,
135
+ canvas.height,
136
+ );
137
+ };
138
+
139
+ // Canvas history functions
140
+ const saveCanvasState = () => {
141
+ if (!canvasRef.current) return;
142
+ const canvas = canvasRef.current;
143
+ const dataUrl = canvas.toDataURL();
144
+ const newHistory = history.slice(0, historyIndex + 1);
145
+ newHistory.push(dataUrl);
146
+ setHistory(newHistory);
147
+ setHistoryIndex(newHistory.length - 1);
148
+ };
149
+
150
+ const restoreCanvasState = (index: number) => {
151
+ if (!canvasRef.current || !history[index]) return;
152
+ const canvas = canvasRef.current;
153
+ const ctx = canvas.getContext('2d');
154
+ const dataUrl = history[index];
155
+ const img = new window.Image();
156
+ img.onload = () => {
157
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
158
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
159
+ };
160
+ img.src = dataUrl;
161
+ };
162
+
163
+ const handleUndo = () => {
164
+ if (historyIndex > 0) {
165
+ const newIndex = historyIndex - 1;
166
+ setHistoryIndex(newIndex);
167
+ restoreCanvasState(newIndex);
168
+ }
169
+ };
170
+
171
+ const handleRedo = () => {
172
+ if (historyIndex < history.length - 1) {
173
+ const newIndex = historyIndex + 1;
174
+ setHistoryIndex(newIndex);
175
+ restoreCanvasState(newIndex);
176
+ }
177
+ };
178
+
179
+ // Get the correct coordinates based on canvas scaling
180
+ const getCoordinates = (e: any) => {
181
+ const canvas = canvasRef.current!;
182
+ const rect = canvas.getBoundingClientRect();
183
+ const scaleX = canvas.width / rect.width;
184
+ const scaleY = canvas.height / rect.height;
185
+ return {
186
+ x:
187
+ (e.nativeEvent.offsetX ||
188
+ e.nativeEvent.touches?.[0]?.clientX - rect.left) * scaleX,
189
+ y:
190
+ (e.nativeEvent.offsetY ||
191
+ e.nativeEvent.touches?.[0]?.clientY - rect.top) * scaleY,
192
+ };
193
+ };
194
+
195
+ const startDrawing = (e: any) => {
196
+ const canvas = canvasRef.current!;
197
+ const ctx = canvas.getContext('2d')!;
198
+ const {x, y} = getCoordinates(e);
199
+ if (e.type === 'touchstart') {
200
+ e.preventDefault();
201
+ }
202
+ ctx.beginPath();
203
+ ctx.moveTo(x, y);
204
+ setIsDrawing(true);
205
+ };
206
+
207
+ const draw = (e: any) => {
208
+ if (!isDrawing) return;
209
+ if (e.type === 'touchmove') {
210
+ e.preventDefault();
211
+ }
212
+ const canvas = canvasRef.current!;
213
+ const ctx = canvas.getContext('2d')!;
214
+ const {x, y} = getCoordinates(e);
215
+ ctx.lineWidth = brushSize;
216
+ ctx.lineCap = 'round';
217
+ ctx.strokeStyle = isErasing ? '#FFFFFF' : brushColor;
218
+ ctx.lineTo(x, y);
219
+ ctx.stroke();
220
+ };
221
+
222
+ const stopDrawing = () => {
223
+ if (!isDrawing) return;
224
+ setIsDrawing(false);
225
+ saveCanvasState();
226
+ };
227
+
228
+ const handleClear = () => {
229
+ if (canvasRef.current) {
230
+ initializeCanvas();
231
+ const dataUrl = canvasRef.current.toDataURL();
232
+ setHistory([dataUrl]);
233
+ setHistoryIndex(0);
234
+ }
235
+ setGeneratedImage(null);
236
+ backgroundImageRef.current = null;
237
+ setPrompt('');
238
+ };
239
+
240
+ const handleSubmit = async (e: React.FormEvent) => {
241
+ e.preventDefault();
242
+
243
+ const cleanApiKey = apiKey.trim();
244
+
245
+ if (!cleanApiKey) {
246
+ setErrorTitle('API Key Required');
247
+ setErrorMessage(
248
+ 'Please enter your Gemini API Key to continue the process.',
249
+ );
250
+ setShowErrorModal(true);
251
+ return;
252
+ }
253
+
254
+ setIsLoading(true);
255
+ setErrorMessage('');
256
+ setErrorTitle('Failed to generate');
257
+
258
+ try {
259
+ // Create a new GenAI instance right before the call
260
+ const ai = new GoogleGenAI({apiKey: cleanApiKey});
261
+
262
+ if (!canvasRef.current) return;
263
+ const canvas = canvasRef.current;
264
+ const imageB64 = canvas.toDataURL('image/png').split(',')[1];
265
+ const parts = [
266
+ {inlineData: {data: imageB64, mimeType: 'image/png'}},
267
+ {text: prompt},
268
+ ];
269
+
270
+ const response: GenerateContentResponse = await ai.models.generateContent({
271
+ model: selectedModel,
272
+ contents: [{parts}],
273
+ config: {
274
+ imageConfig: {
275
+ aspectRatio: '16:9',
276
+ },
277
+ },
278
+ });
279
+
280
+ let newImageData: string | null = null;
281
+ // Find the image part, do not assume it is the first part.
282
+ for (const part of response.candidates[0].content.parts) {
283
+ if (part.inlineData) {
284
+ newImageData = part.inlineData.data;
285
+ break;
286
+ }
287
+ }
288
+
289
+ if (newImageData) {
290
+ const imageUrl = `data:image/png;base64,${newImageData}`;
291
+ setGeneratedImage(imageUrl);
292
+ } else {
293
+ setErrorMessage(
294
+ 'Failed to generate image from the response. Please try again.',
295
+ );
296
+ setShowErrorModal(true);
297
+ }
298
+ } catch (error: any) {
299
+ console.error('Error submitting:', error);
300
+ let message =
301
+ error.message ||
302
+ 'An unexpected error occurred. Check the console for details.';
303
+
304
+ // Robust 403 check
305
+ if (
306
+ error.status === 403 ||
307
+ error.code === 403 ||
308
+ message.includes('403') ||
309
+ message.includes('PERMISSION_DENIED')
310
+ ) {
311
+ setErrorTitle('Permission Denied');
312
+ message = selectedModel.includes('pro')
313
+ ? 'The Gemini 3 Pro Image model requires a paid API key from a Google Cloud Project with billing enabled. Please switch to "Gemini 2.5 Flash" or provide a valid key.'
314
+ : 'The API key provided does not have permission to access the Gemini API. Please ensure your key is valid and has the necessary permissions.';
315
+ }
316
+
317
+ setErrorMessage(message);
318
+ setShowErrorModal(true);
319
+ } finally {
320
+ setIsLoading(false);
321
+ }
322
+ };
323
+
324
+ const closeErrorModal = () => {
325
+ setShowErrorModal(false);
326
+ };
327
+
328
+ const closeInfoModal = () => {
329
+ setShowInfoModal(false);
330
+ };
331
+
332
+ const toggleBrushMenu = () => {
333
+ setShowBrushMenu(!showBrushMenu);
334
+ setShowColorMenu(false);
335
+ setShowModelMenu(false);
336
+ };
337
+
338
+ const toggleColorMenu = () => {
339
+ setShowColorMenu(!showColorMenu);
340
+ setShowBrushMenu(false);
341
+ setShowModelMenu(false);
342
+ };
343
+
344
+ const toggleModelMenu = () => {
345
+ setShowModelMenu(!showModelMenu);
346
+ setShowBrushMenu(false);
347
+ setShowColorMenu(false);
348
+ };
349
+
350
+ const toggleEraser = () => {
351
+ setIsErasing(!isErasing);
352
+ // If enabling eraser, close menus
353
+ if (!isErasing) {
354
+ setShowBrushMenu(false);
355
+ setShowColorMenu(false);
356
+ setShowModelMenu(false);
357
+ }
358
+ };
359
+
360
+ const presetColors = [
361
+ '#000000',
362
+ '#FFFFFF',
363
+ '#FF0000',
364
+ '#00FF00',
365
+ '#0000FF',
366
+ '#FFFF00',
367
+ '#00FFFF',
368
+ '#FF00FF',
369
+ '#808080',
370
+ '#A52A2A',
371
+ ];
372
+
373
+ useEffect(() => {
374
+ const canvas = canvasRef.current;
375
+ if (!canvas) return;
376
+
377
+ const preventTouchDefault = (e: TouchEvent) => {
378
+ if (isDrawing) {
379
+ e.preventDefault();
380
+ }
381
+ };
382
+
383
+ canvas.addEventListener('touchstart', preventTouchDefault, {
384
+ passive: false,
385
+ });
386
+ canvas.addEventListener('touchmove', preventTouchDefault, {
387
+ passive: false,
388
+ });
389
+
390
+ return () => {
391
+ canvas.removeEventListener('touchstart', preventTouchDefault);
392
+ canvas.removeEventListener('touchmove', preventTouchDefault);
393
+ };
394
+ }, [isDrawing]);
395
+
396
+ return (
397
+ <>
398
+ <div className="min-h-screen flex flex-col justify-start items-center p-4 pt-12">
399
+ <main className="container mx-auto max-w-5xl w-full">
400
+ <div className="flex flex-col sm:flex-row sm:justify-between sm:items-end mb-6 gap-4">
401
+ <div className="flex items-end gap-4">
402
+ <button
403
+ type="button"
404
+ onClick={() => setShowInfoModal(true)}
405
+ className="button-log">
406
+ <Info
407
+ className="w-5 h-5"
408
+ aria-label="Information"
409
+ />
410
+ </button>
411
+
412
+ {/* Brush Size Selector */}
413
+ <div className="relative">
414
+ <button
415
+ type="button"
416
+ onClick={toggleBrushMenu}
417
+ className="button-log"
418
+ title="Brush Size">
419
+ <Brush className="w-5 h-5" aria-label="Brush Settings" />
420
+ </button>
421
+ {showBrushMenu && (
422
+ <div className="absolute top-12 left-0 z-50 p-3 bg-[var(--bg-color)] border-2 border-[var(--main-color)] shadow-[4px_4px_var(--main-color)] rounded-[5px] flex flex-col gap-2 w-48">
423
+ <label
424
+ htmlFor="brush-size"
425
+ className="text-sm font-bold flex justify-between">
426
+ <span>Size</span>
427
+ <span>{brushSize}px</span>
428
+ </label>
429
+ <input
430
+ id="brush-size"
431
+ type="range"
432
+ min="1"
433
+ max="50"
434
+ value={brushSize}
435
+ onChange={(e) => setBrushSize(Number(e.target.value))}
436
+ className="w-full"
437
+ />
438
+ <div className="flex justify-center mt-2 h-12 items-center bg-white border border-black rounded shadow-sm">
439
+ <div
440
+ style={{
441
+ width: `${brushSize}px`,
442
+ height: `${brushSize}px`,
443
+ backgroundColor: brushColor,
444
+ borderRadius: '50%',
445
+ }}
446
+ />
447
+ </div>
448
+ </div>
449
+ )}
450
+ </div>
451
+
452
+ {/* Brush Color Selector */}
453
+ <div className="relative">
454
+ <button
455
+ type="button"
456
+ onClick={toggleColorMenu}
457
+ className="button-log"
458
+ title="Brush Color">
459
+ <Palette
460
+ className="w-5 h-5"
461
+ style={{color: isErasing ? 'inherit' : brushColor}}
462
+ aria-label="Brush Color"
463
+ />
464
+ </button>
465
+ {showColorMenu && (
466
+ <div className="absolute top-12 left-0 z-50 p-3 bg-[var(--bg-color)] border-2 border-[var(--main-color)] shadow-[4px_4px_var(--main-color)] rounded-[5px] flex flex-col gap-2 w-48">
467
+ <label className="text-sm font-bold">Color</label>
468
+ <div className="grid grid-cols-5 gap-2">
469
+ {presetColors.map((color) => (
470
+ <button
471
+ key={color}
472
+ onClick={() => {
473
+ setBrushColor(color);
474
+ setIsErasing(false);
475
+ setShowColorMenu(false);
476
+ }}
477
+ className={`w-6 h-6 rounded-full border border-black shadow-sm ${
478
+ brushColor === color && !isErasing ? 'ring-2 ring-offset-1 ring-black' : ''
479
+ }`}
480
+ style={{backgroundColor: color}}
481
+ title={color}
482
+ />
483
+ ))}
484
+ </div>
485
+ <div className="flex items-center gap-2 mt-2 pt-2 border-t border-[var(--main-color)]">
486
+ <span className="text-xs font-bold">Custom:</span>
487
+ <input
488
+ type="color"
489
+ value={brushColor}
490
+ onChange={(e) => {
491
+ setBrushColor(e.target.value);
492
+ setIsErasing(false);
493
+ }}
494
+ className="h-8 flex-1 cursor-pointer bg-transparent"
495
+ />
496
+ </div>
497
+ </div>
498
+ )}
499
+ </div>
500
+
501
+ {/* Model Selector */}
502
+ <div className="relative">
503
+ <button
504
+ type="button"
505
+ onClick={toggleModelMenu}
506
+ className="button-log"
507
+ title="Select Model">
508
+ <Cpu className="w-5 h-5" aria-label="Select Model" />
509
+ </button>
510
+ {showModelMenu && (
511
+ <div className="absolute top-12 left-0 z-50 p-2 bg-[var(--bg-color)] border-2 border-[var(--main-color)] shadow-[4px_4px_var(--main-color)] rounded-[5px] flex flex-col gap-2 w-56">
512
+ <label className="text-sm font-bold px-1">Model</label>
513
+ <button
514
+ onClick={() => {
515
+ setSelectedModel('gemini-2.5-flash-image');
516
+ setShowModelMenu(false);
517
+ }}
518
+ className={`text-left px-3 py-2 text-sm font-semibold rounded hover:bg-[var(--main-color)] hover:text-[var(--bg-color)] transition-colors ${
519
+ selectedModel === 'gemini-2.5-flash-image' ? 'bg-[var(--main-color)] text-[var(--bg-color)]' : ''
520
+ }`}
521
+ >
522
+ Gemini 2.5 Flash Image
523
+ </button>
524
+ <button
525
+ onClick={() => {
526
+ setSelectedModel('gemini-3-pro-image-preview');
527
+ setShowModelMenu(false);
528
+ }}
529
+ className={`text-left px-3 py-2 text-sm font-semibold rounded hover:bg-[var(--main-color)] hover:text-[var(--bg-color)] transition-colors ${
530
+ selectedModel === 'gemini-3-pro-image-preview' ? 'bg-[var(--main-color)] text-[var(--bg-color)]' : ''
531
+ }`}
532
+ >
533
+ Gemini 3 Pro Image
534
+ </button>
535
+ </div>
536
+ )}
537
+ </div>
538
+ </div>
539
+
540
+ <div className="flex items-end gap-4">
541
+ <div className="flex flex-col items-end">
542
+ <label
543
+ htmlFor="api-key-input"
544
+ className="block text-sm font-bold mb-1 text-[var(--font-color)]">
545
+ Gemini API Key
546
+ </label>
547
+ <input
548
+ id="api-key-input"
549
+ type="password"
550
+ value={apiKey}
551
+ onChange={(e) => setApiKey(e.target.value)}
552
+ placeholder="Enter API Key"
553
+ className="custom-input !w-64"
554
+ />
555
+ </div>
556
+ <button
557
+ type="button"
558
+ onClick={handleClear}
559
+ className="button-log">
560
+ <Trash2
561
+ className="w-5 h-5"
562
+ aria-label="Clear Canvas"
563
+ />
564
+ </button>
565
+ <button
566
+ type="button"
567
+ onClick={toggleDarkMode}
568
+ className="button-log"
569
+ title={isDarkMode ? 'Light Mode' : 'Dark Mode'}>
570
+ {isDarkMode ? (
571
+ <Sun className="w-5 h-5" aria-label="Switch to Light Mode" />
572
+ ) : (
573
+ <Moon className="w-5 h-5" aria-label="Switch to Dark Mode" />
574
+ )}
575
+ </button>
576
+ </div>
577
+ </div>
578
+
579
+ <div className="w-full mb-6">
580
+ <div className="relative w-full">
581
+ <canvas
582
+ ref={canvasRef}
583
+ width={960}
584
+ height={540}
585
+ onMouseDown={startDrawing}
586
+ onMouseMove={draw}
587
+ onMouseUp={stopDrawing}
588
+ onMouseLeave={stopDrawing}
589
+ onTouchStart={startDrawing}
590
+ onTouchMove={draw}
591
+ onTouchEnd={stopDrawing}
592
+ className={`canvas-custom w-full sm:h-[60vh] h-[40vh] min-h-[320px] touch-none ${isErasing ? 'cursor-eraser' : ''}`}
593
+ />
594
+
595
+ {/* Eraser - Top Left */}
596
+ <div className="absolute top-2 left-2">
597
+ <button
598
+ onClick={toggleEraser}
599
+ className={`button-log-small ${isErasing ? 'ring-2 ring-[var(--main-color)]' : ''}`}
600
+ aria-label={isErasing ? "Switch to Brush" : "Eraser"}
601
+ title={isErasing ? "Switch to Brush" : "Eraser"}>
602
+ <Eraser className="w-5 h-5" />
603
+ </button>
604
+ </div>
605
+
606
+ <div className="absolute top-2 right-2 flex gap-2">
607
+ <button
608
+ onClick={handleUndo}
609
+ disabled={historyIndex <= 0}
610
+ className="button-log-small"
611
+ aria-label="Undo">
612
+ <Undo2 className="w-5 h-5" />
613
+ </button>
614
+ <button
615
+ onClick={handleRedo}
616
+ disabled={historyIndex >= history.length - 1}
617
+ className="button-log-small"
618
+ aria-label="Redo">
619
+ <Redo2 className="w-5 h-5" />
620
+ </button>
621
+ </div>
622
+ </div>
623
+ </div>
624
+
625
+ {/* Input form */}
626
+ <form onSubmit={handleSubmit} className="w-full">
627
+ <div className="relative">
628
+ <input
629
+ type="text"
630
+ value={prompt}
631
+ onChange={(e) => setPrompt(e.target.value)}
632
+ placeholder="Describe what to generate or how to edit the drawing..."
633
+ className="prompt-input"
634
+ required
635
+ />
636
+ <button
637
+ type="submit"
638
+ disabled={isLoading}
639
+ className="button-confirm absolute right-0 top-0">
640
+ {isLoading ? (
641
+ <LoaderCircle
642
+ className="w-6 h-6 animate-spin"
643
+ aria-label="Loading"
644
+ />
645
+ ) : (
646
+ <ArrowUp
647
+ className="w-6 h-6"
648
+ aria-label="Submit"
649
+ />
650
+ )}
651
+ </button>
652
+ </div>
653
+ </form>
654
+ </main>
655
+ {/* Info Modal */}
656
+ {showInfoModal && (
657
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
658
+ <div className="modal-content max-w-md w-full">
659
+ <div className="flex justify-between items-start mb-4">
660
+ <h3 className="title text-xl font-bold">Information</h3>
661
+ <button
662
+ onClick={closeInfoModal}
663
+ className="button-log-small -translate-y-1 -translate-x-1">
664
+ <X className="w-5 h-5" />
665
+ </button>
666
+ </div>
667
+ <div className="space-y-4 font-medium text-[var(--font-color)] break-words">
668
+ <p>
669
+ Nano-banana-pro-sketch-board is a web-based app where you can
670
+ draw or sketch anything and transform it into your desired
671
+ style. The Gemini API key you provide is used only on your
672
+ device and it will be removed when the page is closed or
673
+ refreshed. The key will not be exposed anywhere.
674
+ </p>
675
+ <p>
676
+ About: This app is powered by the {selectedModel === 'gemini-2.5-flash-image' ? 'Gemini 2.5 Flash Image' : 'Gemini 3 Pro Image Preview'} / Gemini 3 Pro Image Preview model
677
+ through the Gemini API and built by{' '}
678
+ <a
679
+ href="https://huggingface.co/prithivMLmods"
680
+ target="_blank"
681
+ rel="noopener noreferrer"
682
+ className="text-blue-600 hover:underline">
683
+ Prithiv Sakthi
684
+ </a>
685
+ .
686
+ </p>
687
+ </div>
688
+ </div>
689
+ </div>
690
+ )}
691
+ {/* Error Modal */}
692
+ {showErrorModal && (
693
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
694
+ <div className="modal-content max-w-md w-full">
695
+ <div className="flex justify-between items-start mb-4">
696
+ <h3 className="title text-xl font-bold">{errorTitle}</h3>
697
+ <button
698
+ onClick={closeErrorModal}
699
+ className="button-log-small -translate-y-1 -translate-x-1">
700
+ <X className="w-5 h-5" />
701
+ </button>
702
+ </div>
703
+ <p className="font-medium text-[var(--font-color)] break-words">
704
+ {errorMessage}
705
+ </p>
706
+ </div>
707
+ </div>
708
+ )}
709
+ </div>
710
+ </>
711
+ );
712
+ }
index.css ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ :root {
3
+ --input-focus: #2d8cf0;
4
+ --font-color: #323232;
5
+ --font-color-sub: #666;
6
+ --bg-color: beige;
7
+ --main-color: black;
8
+ --page-bg: lightblue;
9
+ --cursor-crosshair: 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="%23000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>') 12 12, crosshair;
10
+ --cursor-eraser: 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="%23000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/></svg>') 0 24, auto;
11
+ }
12
+
13
+ [data-theme='dark'] {
14
+ --input-focus: #2d8cf0;
15
+ --font-color: #323232;
16
+ --font-color-sub: #666;
17
+ --bg-color: #fff;
18
+ --main-color: #323232;
19
+ --page-bg: lightgrey;
20
+ --cursor-crosshair: 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="%23323232" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>') 12 12, crosshair;
21
+ --cursor-eraser: 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="%23323232" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/></svg>') 0 24, auto;
22
+ }
23
+
24
+ html {
25
+ color-scheme: light dark;
26
+ background-color: var(--page-bg);
27
+ color: var(--font-color);
28
+ font-family: sans-serif;
29
+ transition: background-color 0.3s ease, color 0.3s ease;
30
+ }
31
+
32
+ .title {
33
+ color: var(--font-color);
34
+ font-weight: 900;
35
+ }
36
+
37
+ .custom-input {
38
+ height: 40px;
39
+ border-radius: 5px;
40
+ border: 2px solid var(--main-color);
41
+ background-color: var(--bg-color);
42
+ box-shadow: 4px 4px var(--main-color);
43
+ font-size: 15px;
44
+ font-weight: 600;
45
+ color: var(--font-color);
46
+ padding: 5px 10px;
47
+ outline: none;
48
+ transition: all 0.2s ease-in-out;
49
+ }
50
+
51
+ .custom-input::placeholder {
52
+ color: var(--font-color-sub);
53
+ opacity: 0.8;
54
+ }
55
+
56
+ .custom-input:focus {
57
+ border: 2px solid var(--input-focus);
58
+ }
59
+
60
+ .button-log {
61
+ cursor: pointer;
62
+ width: 40px;
63
+ height: 40px;
64
+ border-radius: 5px;
65
+ border: 2px solid var(--main-color);
66
+ background-color: var(--bg-color);
67
+ box-shadow: 4px 4px var(--main-color);
68
+ color: var(--font-color);
69
+ display: flex;
70
+ justify-content: center;
71
+ align-items: center;
72
+ transition: all 0.1s ease-in-out;
73
+ }
74
+
75
+ .button-log:active {
76
+ box-shadow: 0px 0px var(--main-color);
77
+ transform: translate(3px, 3px);
78
+ }
79
+
80
+ .canvas-custom {
81
+ border-radius: 5px;
82
+ border: 2px solid var(--main-color) !important;
83
+ background-color: white;
84
+ box-shadow: 4px 4px var(--main-color);
85
+ cursor: var(--cursor-crosshair);
86
+ transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
87
+ }
88
+
89
+ .cursor-eraser {
90
+ cursor: var(--cursor-eraser) !important;
91
+ }
92
+
93
+ .button-log-small {
94
+ cursor: pointer;
95
+ padding: 0.5rem;
96
+ border-radius: 5px;
97
+ border: 2px solid var(--main-color);
98
+ background-color: var(--bg-color);
99
+ box-shadow: 4px 4px var(--main-color);
100
+ color: var(--font-color);
101
+ display: flex;
102
+ justify-content: center;
103
+ align-items: center;
104
+ transition: all 0.1s ease-in-out;
105
+ }
106
+
107
+ .button-log-small:active:not(:disabled) {
108
+ box-shadow: 0px 0px var(--main-color);
109
+ transform: translate(3px, 3px);
110
+ }
111
+
112
+ .button-log-small:disabled {
113
+ opacity: 0.5;
114
+ cursor: not-allowed;
115
+ }
116
+
117
+ .prompt-input {
118
+ width: 100%;
119
+ height: 52px;
120
+ border-radius: 5px;
121
+ border: 2px solid var(--main-color);
122
+ background-color: var(--bg-color);
123
+ box-shadow: 4px 4px var(--main-color);
124
+ font-size: 1rem;
125
+ font-weight: 600;
126
+ color: var(--font-color);
127
+ padding: 5px 10px;
128
+ padding-right: 60px; /* space for the button */
129
+ outline: none;
130
+ transition: all 0.2s ease-in-out;
131
+ }
132
+
133
+ .prompt-input::placeholder {
134
+ color: var(--font-color-sub);
135
+ opacity: 0.8;
136
+ }
137
+
138
+ .prompt-input:focus {
139
+ border: 2px solid var(--input-focus);
140
+ }
141
+
142
+ .button-confirm {
143
+ cursor: pointer;
144
+ height: 100%;
145
+ width: 48px;
146
+ border-radius: 5px;
147
+ border: none;
148
+ background-color: transparent;
149
+ font-weight: 600;
150
+ color: var(--font-color);
151
+ display: flex;
152
+ justify-content: center;
153
+ align-items: center;
154
+ transition: all 0.1s ease-in-out;
155
+ }
156
+
157
+ .button-confirm:active:not(:disabled) {
158
+ transform: translate(3px, 3px);
159
+ }
160
+
161
+ .button-confirm:disabled {
162
+ color: var(--font-color-sub);
163
+ cursor: not-allowed;
164
+ opacity: 0.5;
165
+ }
166
+
167
+ .modal-content {
168
+ padding: 24px;
169
+ background: var(--bg-color);
170
+ border-radius: 5px;
171
+ border: 2px solid var(--main-color);
172
+ box-shadow: 4px 4px var(--main-color);
173
+ color: var(--font-color);
174
+ }
175
+
176
+ /* Custom Range Slider for Retro Theme */
177
+ input[type=range] {
178
+ -webkit-appearance: none;
179
+ width: 100%;
180
+ background: transparent;
181
+ }
182
+
183
+ input[type=range]:focus {
184
+ outline: none;
185
+ }
186
+
187
+ input[type=range]::-webkit-slider-thumb {
188
+ -webkit-appearance: none;
189
+ height: 16px;
190
+ width: 16px;
191
+ border: 2px solid var(--main-color);
192
+ border-radius: 50%;
193
+ background: var(--bg-color);
194
+ cursor: pointer;
195
+ margin-top: -6px;
196
+ box-shadow: 1px 1px var(--main-color);
197
+ }
198
+
199
+ input[type=range]::-webkit-slider-runnable-track {
200
+ width: 100%;
201
+ height: 4px;
202
+ cursor: pointer;
203
+ background: var(--main-color);
204
+ border-radius: 2px;
205
+ }
206
+
207
+ input[type=range]::-moz-range-thumb {
208
+ height: 16px;
209
+ width: 16px;
210
+ border: 2px solid var(--main-color);
211
+ border-radius: 50%;
212
+ background: var(--bg-color);
213
+ cursor: pointer;
214
+ box-shadow: 1px 1px var(--main-color);
215
+ }
216
+
217
+ input[type=range]::-moz-range-track {
218
+ width: 100%;
219
+ height: 4px;
220
+ cursor: pointer;
221
+ background: var(--main-color);
222
+ border-radius: 2px;
223
+ }
index.html ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script type="importmap">
2
+ {
3
+ "imports": {
4
+ "@google/genai": "https://esm.sh/@google/genai@^0.7.0",
5
+ "react": "https://esm.sh/react@^19.0.0",
6
+ "react/": "https://esm.sh/react@^19.0.0/",
7
+ "react-dom/": "https://esm.sh/react-dom@^19.0.0/",
8
+ "lucide-react": "https://esm.sh/lucide-react@^0.487.0",
9
+ "@tailwindcss/browser": "https://esm.sh/@tailwindcss/browser@^4.1.2"
10
+ }
11
+ }
12
+ </script>
13
+ <link rel="stylesheet" href="index.css">
14
+ <div id="root"></div><link rel="stylesheet" href="/index.css">
15
+ <script type="module" src="/index.tsx"></script>
index.tsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import '@tailwindcss/browser';
6
+
7
+ import ReactDOM from 'react-dom/client';
8
+ import Home from './Home';
9
+
10
+ const root = ReactDOM.createRoot(document.getElementById('root'));
11
+ root.render(<Home />);
metadata.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "name": "nanobanana-sketch-to-image",
3
+ "description": "Turn sketches into stunning visuals. Draw, prompt, and let Gemini transform your ideas into creative images in seconds.",
4
+ "requestFramePermissions": []
5
+ }
package.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "nanobanana-sketch-to-image",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@google/genai": "^0.7.0",
13
+ "react": "^19.0.0",
14
+ "react-dom": "^19.0.0",
15
+ "lucide-react": "^0.487.0",
16
+ "@tailwindcss/browser": "^4.1.2"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^22.14.0",
20
+ "@vitejs/plugin-react": "^5.0.0",
21
+ "typescript": "~5.8.2",
22
+ "vite": "^6.2.0"
23
+ }
24
+ }
tsconfig.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "experimentalDecorators": true,
5
+ "useDefineForClassFields": false,
6
+ "module": "ESNext",
7
+ "lib": [
8
+ "ES2022",
9
+ "DOM",
10
+ "DOM.Iterable"
11
+ ],
12
+ "skipLibCheck": true,
13
+ "types": [
14
+ "node"
15
+ ],
16
+ "moduleResolution": "bundler",
17
+ "isolatedModules": true,
18
+ "moduleDetection": "force",
19
+ "allowJs": true,
20
+ "jsx": "react-jsx",
21
+ "paths": {
22
+ "@/*": [
23
+ "./*"
24
+ ]
25
+ },
26
+ "allowImportingTsExtensions": true,
27
+ "noEmit": true
28
+ }
29
+ }
vite.config.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'path';
2
+ import { defineConfig, loadEnv } from 'vite';
3
+ import react from '@vitejs/plugin-react';
4
+
5
+ export default defineConfig(({ mode }) => {
6
+ const env = loadEnv(mode, '.', '');
7
+ return {
8
+ server: {
9
+ port: 3000,
10
+ host: '0.0.0.0',
11
+ },
12
+ plugins: [react()],
13
+ define: {
14
+ 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
15
+ 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
16
+ },
17
+ resolve: {
18
+ alias: {
19
+ '@': path.resolve(__dirname, '.'),
20
+ }
21
+ }
22
+ };
23
+ });