diff --git a/README.md b/README.md
index 817f1b2..c07812f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,7 @@
-# 🍌 Nano Banana Image Editor
+# 🍌 Nano Banana Image Editor
+Release Version: (v1.0)
-> **🚀 Get Your Free Copy!** Join the [Vibe Coding is Life Skool Community](https://www.skool.com/vibe-coding-is-life/about?ref=456537abaf37491cbcc6976f3c26af41) and get a **1-click Bolt.new installation clone** of this app, plus access to live build sessions, exclusive project downloads, AI prompts, masterclasses, and the best vibe coding community on the web!
+> **⏬ Get Your Free 1-Click Install Copy!** Join the [Vibe Coding is Life Skool Community](https://www.skool.com/vibe-coding-is-life/about?ref=456537abaf37491cbcc6976f3c26af41) and get a **1-click Bolt.new installation clone** of this app, plus access to live build sessions, exclusive project downloads, AI prompts, masterclasses, and the best vibe coding community on the web!
---
@@ -8,7 +9,7 @@
A production-ready React + TypeScript application for delightful image generation and conversational, region-aware revisions using Google's Gemini 2.5 Flash Image model. Built with modern web technologies and designed for both creators and developers.
-
+
## ✨ Key Features
@@ -207,17 +208,16 @@ We welcome contributions! Please:
- **Client-side API calls** - Currently uses direct API calls (implement backend proxy for production)
- **Browser compatibility** - Requires modern browsers with Canvas and WebGL support
- **Rate limits** - Subject to Google AI Studio rate limits
-- **Image size** - Optimized for 1024×1024 outputs (Gemini model limitation)
+- **Image size** - Optimized for 1024×1024 outputs (Gemini model output dimensions may vary)
-## 🎯 Roadmap
+## 🎯 Suggested Updates
- [ ] Backend API proxy implementation
- [ ] User authentication and project sharing
- [ ] Advanced brush tools and selection methods
-- [ ] Batch processing capabilities
- [ ] Plugin system for custom filters
- [ ] Integration with cloud storage providers
---
-**Built with ❤️ by the Vibe Coding community** | **Powered by Gemini 2.5 Flash Image** | **Made with Bolt.new**
+**Built by [Mark Fulton](https://markfulton.com) ** | **Powered by Gemini 2.5 Flash Image** | **Made with Bolt.new**
\ No newline at end of file
diff --git a/index.html b/index.html
index 6e17394..1b959b6 100644
--- a/index.html
+++ b/index.html
@@ -49,7 +49,7 @@
-
Nano Banana Image Editor - AI Image Generator & Editor
+ Nano Banana AI Image Editor - AI Image Generator & Editor
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..375bfb4
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { cn } from './utils/cn';
+import { Header } from './components/Header';
+import { PromptComposer } from './components/PromptComposer';
+import { ImageCanvas } from './components/ImageCanvas';
+import { HistoryPanel } from './components/HistoryPanel';
+import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
+import { useAppStore } from './store/useAppStore';
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ retry: 2,
+ },
+ },
+});
+
+function AppContent() {
+ useKeyboardShortcuts();
+
+ const { showPromptPanel, setShowPromptPanel, showHistory, setShowHistory } = useAppStore();
+
+ // Set mobile defaults on mount
+ React.useEffect(() => {
+ const checkMobile = () => {
+ const isMobile = window.innerWidth < 768;
+ if (isMobile) {
+ setShowPromptPanel(false);
+ setShowHistory(false);
+ }
+ };
+
+ checkMobile();
+ window.addEventListener('resize', checkMobile);
+ return () => window.removeEventListener('resize', checkMobile);
+ }, [setShowPromptPanel, setShowHistory]);
+
+ return (
+
+ );
+}
+
+function App() {
+ return (
+
+
+
+ );
+}
+
+export default App;
\ No newline at end of file
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
new file mode 100644
index 0000000..44b82b2
--- /dev/null
+++ b/src/components/Header.tsx
@@ -0,0 +1,41 @@
+import React, { useState } from 'react';
+import { Button } from './ui/Button';
+import { HelpCircle } from 'lucide-react';
+import { InfoModal } from './InfoModal';
+
+export const Header: React.FC = () => {
+ const [showInfoModal, setShowInfoModal] = useState(false);
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
\ No newline at end of file
diff --git a/src/components/HistoryPanel.tsx b/src/components/HistoryPanel.tsx
new file mode 100644
index 0000000..77a2129
--- /dev/null
+++ b/src/components/HistoryPanel.tsx
@@ -0,0 +1,408 @@
+import React from 'react';
+import { useAppStore } from '../store/useAppStore';
+import { Button } from './ui/Button';
+import { History, Download, Image as ImageIcon, Layers } from 'lucide-react';
+import { cn } from '../utils/cn';
+import { ImagePreviewModal } from './ImagePreviewModal';
+
+export const HistoryPanel: React.FC = () => {
+ const {
+ currentProject,
+ canvasImage,
+ selectedGenerationId,
+ selectedEditId,
+ selectGeneration,
+ selectEdit,
+ showHistory,
+ setShowHistory,
+ setCanvasImage,
+ selectedTool
+ } = useAppStore();
+
+ const [previewModal, setPreviewModal] = React.useState<{
+ open: boolean;
+ imageUrl: string;
+ title: string;
+ description?: string;
+ }>({
+ open: false,
+ imageUrl: '',
+ title: '',
+ description: ''
+ });
+
+ const generations = currentProject?.generations || [];
+ const edits = currentProject?.edits || [];
+
+ // Get current image dimensions
+ const [imageDimensions, setImageDimensions] = React.useState<{ width: number; height: number } | null>(null);
+
+ React.useEffect(() => {
+ if (canvasImage) {
+ const img = new Image();
+ img.onload = () => {
+ setImageDimensions({ width: img.width, height: img.height });
+ };
+ img.src = canvasImage;
+ } else {
+ setImageDimensions(null);
+ }
+ }, [canvasImage]);
+
+ if (!showHistory) {
+ return (
+
+
setShowHistory(true)}
+ className="w-6 h-16 bg-gray-800 hover:bg-gray-700 rounded-l-lg border border-r-0 border-gray-700 flex items-center justify-center transition-colors group"
+ title="Show History Panel"
+ >
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
History & Variants
+
+
setShowHistory(!showHistory)}
+ className="h-6 w-6"
+ title="Hide History Panel"
+ >
+ ×
+
+
+
+ {/* Variants Grid */}
+
+
Current Variants
+ {generations.length === 0 && edits.length === 0 ? (
+
+
🖼️
+
No generations yet
+
+ ) : (
+
+ {/* Show generations */}
+ {generations.slice(-2).map((generation, index) => (
+
{
+ selectGeneration(generation.id);
+ if (generation.outputAssets[0]) {
+ setCanvasImage(generation.outputAssets[0].url);
+ }
+ }}
+ >
+ {generation.outputAssets[0] ? (
+ <>
+
+ >
+ ) : (
+
+ )}
+
+ {/* Variant Number */}
+
+ #{index + 1}
+
+
+ ))}
+
+ {/* Show edits */}
+ {edits.slice(-2).map((edit, index) => (
+
{
+ if (edit.outputAssets[0]) {
+ setCanvasImage(edit.outputAssets[0].url);
+ selectEdit(edit.id);
+ selectGeneration(null);
+ }
+ }}
+ >
+ {edit.outputAssets[0] ? (
+
+ ) : (
+
+ )}
+
+ {/* Edit Label */}
+
+ Edit #{index + 1}
+
+
+ ))}
+
+ )}
+
+
+ {/* Current Image Info */}
+ {(canvasImage || imageDimensions) && (
+
+
Current Image
+
+ {imageDimensions && (
+
+ Dimensions:
+ {imageDimensions.width} × {imageDimensions.height}
+
+ )}
+
+ Mode:
+ {selectedTool}
+
+
+
+ )}
+
+ {/* Generation Details */}
+
+
Generation Details
+ {(() => {
+ const gen = generations.find(g => g.id === selectedGenerationId);
+ const selectedEdit = edits.find(e => e.id === selectedEditId);
+
+ if (gen) {
+ return (
+
+
+
+
Prompt:
+
{gen.prompt}
+
+
+ Model:
+ {gen.modelVersion}
+
+ {gen.parameters.seed && (
+
+ Seed:
+ {gen.parameters.seed}
+
+ )}
+
+
+ {/* Reference Images */}
+ {gen.sourceAssets.length > 0 && (
+
+
Reference Images
+
+ {gen.sourceAssets.map((asset, index) => (
+
setPreviewModal({
+ open: true,
+ imageUrl: asset.url,
+ title: `Reference Image ${index + 1}`,
+ description: 'This reference image was used to guide the generation'
+ })}
+ className="relative aspect-square rounded border border-gray-700 hover:border-gray-600 transition-colors overflow-hidden group"
+ >
+
+
+
+
+
+ Ref {index + 1}
+
+
+ ))}
+
+
+ )}
+
+ );
+ } else if (selectedEdit) {
+ const parentGen = generations.find(g => g.id === selectedEdit.parentGenerationId);
+ return (
+
+
+
+
Edit Instruction:
+
{selectedEdit.instruction}
+
+
+ Type:
+ Image Edit
+
+
+ Created:
+ {new Date(selectedEdit.timestamp).toLocaleTimeString()}
+
+ {selectedEdit.maskAssetId && (
+
+ Mask:
+ Applied
+
+ )}
+
+
+ {/* Parent Generation Reference */}
+ {parentGen && (
+
+
Original Image
+
setPreviewModal({
+ open: true,
+ imageUrl: parentGen.outputAssets[0]?.url || '',
+ title: 'Original Image',
+ description: 'The base image that was edited'
+ })}
+ className="relative aspect-square w-16 rounded border border-gray-700 hover:border-gray-600 transition-colors overflow-hidden group"
+ >
+
+
+
+
+
+
+ )}
+
+ {/* Mask Visualization */}
+ {selectedEdit.maskReferenceAsset && (
+
+
Masked Reference
+
setPreviewModal({
+ open: true,
+ imageUrl: selectedEdit.maskReferenceAsset!.url,
+ title: 'Masked Reference Image',
+ description: 'This image with mask overlay was sent to the AI model to guide the edit'
+ })}
+ className="relative aspect-square w-16 rounded border border-gray-700 hover:border-gray-600 transition-colors overflow-hidden group"
+ >
+
+
+
+
+
+ Mask
+
+
+
+ )}
+
+ );
+ } else {
+ return (
+
+
Select a generation or edit to view details
+
+ );
+ }
+ })()}
+
+
+ {/* Actions */}
+
+ {
+ // Find the currently displayed image (either generation or edit)
+ let imageUrl: string | null = null;
+
+ if (selectedGenerationId) {
+ const gen = generations.find(g => g.id === selectedGenerationId);
+ imageUrl = gen?.outputAssets[0]?.url || null;
+ } else {
+ // If no generation selected, try to get the current canvas image
+ const { canvasImage } = useAppStore.getState();
+ imageUrl = canvasImage;
+ }
+
+ if (imageUrl) {
+ // Handle both data URLs and regular URLs
+ if (imageUrl.startsWith('data:')) {
+ const link = document.createElement('a');
+ link.href = imageUrl;
+ link.download = `nano-banana-${Date.now()}.png`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ } else {
+ // For external URLs, we need to fetch and convert to blob
+ fetch(imageUrl)
+ .then(response => response.blob())
+ .then(blob => {
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `nano-banana-${Date.now()}.png`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ });
+ }
+ }
+ }}
+ disabled={!selectedGenerationId && !useAppStore.getState().canvasImage}
+ >
+
+ Download
+
+
+
+ {/* Image Preview Modal */}
+
setPreviewModal(prev => ({ ...prev, open }))}
+ imageUrl={previewModal.imageUrl}
+ title={previewModal.title}
+ description={previewModal.description}
+ />
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/ImageCanvas.tsx b/src/components/ImageCanvas.tsx
new file mode 100644
index 0000000..c42c402
--- /dev/null
+++ b/src/components/ImageCanvas.tsx
@@ -0,0 +1,370 @@
+import React, { useRef, useEffect, useState } from 'react';
+import { Stage, Layer, Image as KonvaImage, Line } from 'react-konva';
+import { useAppStore } from '../store/useAppStore';
+import { Button } from './ui/Button';
+import { ZoomIn, ZoomOut, RotateCcw, Download, Eye, EyeOff, Eraser } from 'lucide-react';
+import { cn } from '../utils/cn';
+
+export const ImageCanvas: React.FC = () => {
+ const {
+ canvasImage,
+ canvasZoom,
+ setCanvasZoom,
+ canvasPan,
+ setCanvasPan,
+ brushStrokes,
+ addBrushStroke,
+ clearBrushStrokes,
+ showMasks,
+ setShowMasks,
+ selectedTool,
+ isGenerating,
+ brushSize,
+ setBrushSize
+ } = useAppStore();
+
+ const stageRef = useRef(null);
+ const [image, setImage] = useState(null);
+ const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
+ const [isDrawing, setIsDrawing] = useState(false);
+ const [currentStroke, setCurrentStroke] = useState([]);
+
+ // Load image and auto-fit when canvasImage changes
+ useEffect(() => {
+ if (canvasImage) {
+ const img = new window.Image();
+ img.onload = () => {
+ setImage(img);
+
+ // Only auto-fit if this is a new image (no existing zoom/pan state)
+ if (canvasZoom === 1 && canvasPan.x === 0 && canvasPan.y === 0) {
+ // Auto-fit image to canvas
+ const isMobile = window.innerWidth < 768;
+ const padding = isMobile ? 0.9 : 0.8; // Use more of the screen on mobile
+
+ const scaleX = (stageSize.width * padding) / img.width;
+ const scaleY = (stageSize.height * padding) / img.height;
+
+ const maxZoom = isMobile ? 0.3 : 0.8;
+ const optimalZoom = Math.min(scaleX, scaleY, maxZoom);
+
+ setCanvasZoom(optimalZoom);
+
+ // Center the image
+ setCanvasPan({ x: 0, y: 0 });
+ }
+ };
+ img.src = canvasImage;
+ } else {
+ setImage(null);
+ }
+ }, [canvasImage, stageSize, setCanvasZoom, setCanvasPan, canvasZoom, canvasPan]);
+
+ // Handle stage resize
+ useEffect(() => {
+ const updateSize = () => {
+ const container = document.getElementById('canvas-container');
+ if (container) {
+ setStageSize({
+ width: container.offsetWidth,
+ height: container.offsetHeight
+ });
+ }
+ };
+
+ updateSize();
+ window.addEventListener('resize', updateSize);
+ return () => window.removeEventListener('resize', updateSize);
+ }, []);
+
+ const handleMouseDown = (e: any) => {
+ if (selectedTool !== 'mask' || !image) return;
+
+ setIsDrawing(true);
+ const stage = e.target.getStage();
+ const pos = stage.getPointerPosition();
+
+ // Use Konva's getRelativePointerPosition for accurate coordinates
+ const relativePos = stage.getRelativePointerPosition();
+
+ // Calculate image bounds on the stage
+ const imageX = (stageSize.width / canvasZoom - image.width) / 2;
+ const imageY = (stageSize.height / canvasZoom - image.height) / 2;
+
+ // Convert to image-relative coordinates
+ const relativeX = relativePos.x - imageX;
+ const relativeY = relativePos.y - imageY;
+
+ // Check if click is within image bounds
+ if (relativeX >= 0 && relativeX <= image.width && relativeY >= 0 && relativeY <= image.height) {
+ setCurrentStroke([relativeX, relativeY]);
+ }
+ };
+
+ const handleMouseMove = (e: any) => {
+ if (!isDrawing || selectedTool !== 'mask' || !image) return;
+
+ const stage = e.target.getStage();
+ const pos = stage.getPointerPosition();
+
+ // Use Konva's getRelativePointerPosition for accurate coordinates
+ const relativePos = stage.getRelativePointerPosition();
+
+ // Calculate image bounds on the stage
+ const imageX = (stageSize.width / canvasZoom - image.width) / 2;
+ const imageY = (stageSize.height / canvasZoom - image.height) / 2;
+
+ // Convert to image-relative coordinates
+ const relativeX = relativePos.x - imageX;
+ const relativeY = relativePos.y - imageY;
+
+ // Check if within image bounds
+ if (relativeX >= 0 && relativeX <= image.width && relativeY >= 0 && relativeY <= image.height) {
+ setCurrentStroke([...currentStroke, relativeX, relativeY]);
+ }
+ };
+
+ const handleMouseUp = () => {
+ if (!isDrawing || currentStroke.length < 4) {
+ setIsDrawing(false);
+ setCurrentStroke([]);
+ return;
+ }
+
+ setIsDrawing(false);
+ addBrushStroke({
+ id: `stroke-${Date.now()}`,
+ points: currentStroke,
+ brushSize,
+ });
+ setCurrentStroke([]);
+ };
+
+ const handleZoom = (delta: number) => {
+ const newZoom = Math.max(0.1, Math.min(3, canvasZoom + delta));
+ setCanvasZoom(newZoom);
+ };
+
+ const handleReset = () => {
+ if (image) {
+ const isMobile = window.innerWidth < 768;
+ const padding = isMobile ? 0.9 : 0.8;
+ const scaleX = (stageSize.width * padding) / image.width;
+ const scaleY = (stageSize.height * padding) / image.height;
+ const maxZoom = isMobile ? 0.3 : 0.8;
+ const optimalZoom = Math.min(scaleX, scaleY, maxZoom);
+
+ setCanvasZoom(optimalZoom);
+ setCanvasPan({ x: 0, y: 0 });
+ }
+ };
+
+ const handleDownload = () => {
+ if (canvasImage) {
+ if (canvasImage.startsWith('data:')) {
+ const link = document.createElement('a');
+ link.href = canvasImage;
+ link.download = `nano-banana-${Date.now()}.png`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+ }
+ };
+
+ return (
+
+ {/* Toolbar */}
+
+
+ {/* Left side - Zoom controls */}
+
+ handleZoom(-0.1)}>
+
+
+
+ {Math.round(canvasZoom * 100)}%
+
+ handleZoom(0.1)}>
+
+
+
+
+
+
+
+ {/* Right side - Tools and actions */}
+
+ {selectedTool === 'mask' && (
+ <>
+
+ Brush:
+ setBrushSize(parseInt(e.target.value))}
+ className="w-16 h-2 bg-gray-800 rounded-lg appearance-none cursor-pointer slider"
+ />
+ {brushSize}
+
+
+
+
+ >
+ )}
+
+
setShowMasks(!showMasks)}
+ className={cn(showMasks && 'bg-yellow-400/10 border-yellow-400/50')}
+ >
+ {showMasks ? : }
+ Masks
+
+
+ {canvasImage && (
+
+
+ Download
+
+ )}
+
+
+
+
+ {/* Canvas Area */}
+
+ {!image && !isGenerating && (
+
+
+
🍌
+
+ Welcome to Nano Banana Framework
+
+
+ {selectedTool === 'generate'
+ ? 'Start by describing what you want to create in the prompt box'
+ : 'Upload an image to begin editing'
+ }
+
+
+
+ )}
+
+ {isGenerating && (
+
+
+
+
Creating your image...
+
+
+ )}
+
+
{
+ setCanvasPan({
+ x: e.target.x() / canvasZoom,
+ y: e.target.y() / canvasZoom
+ });
+ }}
+ onMouseDown={handleMouseDown}
+ onMousemove={handleMouseMove}
+ onMouseup={handleMouseUp}
+ style={{
+ cursor: selectedTool === 'mask' ? 'crosshair' : 'default'
+ }}
+ >
+
+ {image && (
+
+ )}
+
+ {/* Brush Strokes */}
+ {showMasks && brushStrokes.map((stroke) => (
+
+ ))}
+
+ {/* Current stroke being drawn */}
+ {isDrawing && currentStroke.length > 2 && (
+
+ )}
+
+
+
+
+ {/* Status Bar */}
+
+
+
+ {brushStrokes.length > 0 && (
+ {brushStrokes.length} brush stroke{brushStrokes.length !== 1 ? 's' : ''}
+ )}
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/ImagePreviewModal.tsx b/src/components/ImagePreviewModal.tsx
new file mode 100644
index 0000000..28dcdd7
--- /dev/null
+++ b/src/components/ImagePreviewModal.tsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import * as Dialog from '@radix-ui/react-dialog';
+import { X } from 'lucide-react';
+import { Button } from './ui/Button';
+
+interface ImagePreviewModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ imageUrl: string;
+ title: string;
+ description?: string;
+}
+
+export const ImagePreviewModal: React.FC = ({
+ open,
+ onOpenChange,
+ imageUrl,
+ title,
+ description
+}) => {
+ return (
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+ {description && (
+
{description}
+ )}
+
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/InfoModal.tsx b/src/components/InfoModal.tsx
new file mode 100644
index 0000000..cc0cd3f
--- /dev/null
+++ b/src/components/InfoModal.tsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import * as Dialog from '@radix-ui/react-dialog';
+import { X, ExternalLink, Lightbulb, Download } from 'lucide-react';
+import { Button } from './ui/Button';
+
+interface InfoModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export const InfoModal: React.FC = ({ open, onOpenChange }) => {
+ return (
+
+
+
+
+
+
+ About Nano Banana AI Image Editor
+
+
+
+
+
+
+
+
+
+
+
+ Developed by{' '}
+
+ Mark Fulton
+
+
+
+
+
+
+
+
+
+ Learn to Build AI Apps & More Solutions
+
+
+
+ Learn to vibe code apps like this one and master AI automation, build intelligent agents, and create cutting-edge solutions that drive real business results.
+
+
+ Join the AI Accelerator Program
+
+
+
+
+
+
+
+
+ Get a Copy of This App
+
+
+
+ Get a copy of this app by joining the Vibe Coding is Life Skool community. Live build sessions, app projects, resources and more in the best vibe coding community on the web.
+
+
+ Join Vibe Coding is Life Community
+
+
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/MaskOverlay.tsx b/src/components/MaskOverlay.tsx
new file mode 100644
index 0000000..2abaedc
--- /dev/null
+++ b/src/components/MaskOverlay.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { useAppStore } from '../store/useAppStore';
+
+export const MaskOverlay: React.FC = () => {
+ const { selectedMask, showMasks } = useAppStore();
+
+ if (!showMasks || !selectedMask) return null;
+
+ return (
+
+ {/* Marching ants effect */}
+
+
+ {/* Mask overlay */}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/PromptComposer.tsx b/src/components/PromptComposer.tsx
new file mode 100644
index 0000000..7bcee62
--- /dev/null
+++ b/src/components/PromptComposer.tsx
@@ -0,0 +1,406 @@
+import React, { useState, useRef } from 'react';
+import { Textarea } from './ui/Textarea';
+import { Button } from './ui/Button';
+import { useAppStore } from '../store/useAppStore';
+import { useImageGeneration, useImageEditing } from '../hooks/useImageGeneration';
+import { Upload, Wand2, Edit3, MousePointer, HelpCircle, Menu, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
+import { blobToBase64 } from '../utils/imageUtils';
+import { PromptHints } from './PromptHints';
+import { cn } from '../utils/cn';
+
+export const PromptComposer: React.FC = () => {
+ const {
+ currentPrompt,
+ setCurrentPrompt,
+ selectedTool,
+ setSelectedTool,
+ temperature,
+ setTemperature,
+ seed,
+ setSeed,
+ isGenerating,
+ uploadedImages,
+ addUploadedImage,
+ removeUploadedImage,
+ clearUploadedImages,
+ editReferenceImages,
+ addEditReferenceImage,
+ removeEditReferenceImage,
+ clearEditReferenceImages,
+ canvasImage,
+ setCanvasImage,
+ showPromptPanel,
+ setShowPromptPanel,
+ clearBrushStrokes,
+ } = useAppStore();
+
+ const { generate } = useImageGeneration();
+ const { edit } = useImageEditing();
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ const [showClearConfirm, setShowClearConfirm] = useState(false);
+ const [showHintsModal, setShowHintsModal] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const handleGenerate = () => {
+ if (!currentPrompt.trim()) return;
+
+ if (selectedTool === 'generate') {
+ const referenceImages = uploadedImages
+ .filter(img => img.includes('base64,'))
+ .map(img => img.split('base64,')[1]);
+
+ generate({
+ prompt: currentPrompt,
+ referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
+ temperature,
+ seed: seed || undefined
+ });
+ } else if (selectedTool === 'edit' || selectedTool === 'mask') {
+ edit(currentPrompt);
+ }
+ };
+
+ const handleFileUpload = async (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (file && file.type.startsWith('image/')) {
+ try {
+ const base64 = await blobToBase64(file);
+ const dataUrl = `data:${file.type};base64,${base64}`;
+
+ if (selectedTool === 'generate') {
+ // Add to reference images (max 2)
+ if (uploadedImages.length < 2) {
+ addUploadedImage(dataUrl);
+ }
+ } else if (selectedTool === 'edit') {
+ // For edit mode, add to separate edit reference images (max 2)
+ if (editReferenceImages.length < 2) {
+ addEditReferenceImage(dataUrl);
+ }
+ // Set as canvas image if none exists
+ if (!canvasImage) {
+ setCanvasImage(dataUrl);
+ }
+ } else if (selectedTool === 'mask') {
+ // For mask mode, set as canvas image immediately
+ clearUploadedImages();
+ addUploadedImage(dataUrl);
+ setCanvasImage(dataUrl);
+ }
+ } catch (error) {
+ console.error('Failed to upload image:', error);
+ }
+ }
+ };
+
+ const handleClearSession = () => {
+ setCurrentPrompt('');
+ clearUploadedImages();
+ clearEditReferenceImages();
+ clearBrushStrokes();
+ setCanvasImage(null);
+ setSeed(null);
+ setTemperature(0.7);
+ setShowClearConfirm(false);
+ };
+
+ const tools = [
+ { id: 'generate', icon: Wand2, label: 'Generate', description: 'Create from text' },
+ { id: 'edit', icon: Edit3, label: 'Edit', description: 'Modify existing' },
+ { id: 'mask', icon: MousePointer, label: 'Select', description: 'Click to select' },
+ ] as const;
+
+ if (!showPromptPanel) {
+ return (
+
+
setShowPromptPanel(true)}
+ className="w-6 h-16 bg-gray-800 hover:bg-gray-700 rounded-r-lg border border-l-0 border-gray-700 flex items-center justify-center transition-colors group"
+ title="Show Prompt Panel"
+ >
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
Mode
+
+ setShowHintsModal(true)}
+ className="h-6 w-6"
+ >
+
+
+ setShowPromptPanel(false)}
+ className="h-6 w-6"
+ title="Hide Prompt Panel"
+ >
+ ×
+
+
+
+
+ {tools.map((tool) => (
+ setSelectedTool(tool.id)}
+ className={cn(
+ 'flex flex-col items-center p-3 rounded-lg border transition-all duration-200',
+ selectedTool === tool.id
+ ? 'bg-yellow-400/10 border-yellow-400/50 text-yellow-400'
+ : 'bg-gray-900 border-gray-700 text-gray-400 hover:bg-gray-800 hover:text-gray-300'
+ )}
+ >
+
+ {tool.label}
+
+ ))}
+
+
+
+ {/* File Upload */}
+
+
+
+ {selectedTool === 'generate' ? 'Reference Images' : selectedTool === 'edit' ? 'Style References' : 'Upload Image'}
+
+ {selectedTool === 'mask' && (
+
Edit an image with masks
+ )}
+ {selectedTool === 'generate' && (
+
Optional, up to 2 images
+ )}
+ {selectedTool === 'edit' && (
+
+ {canvasImage ? 'Optional style references, up to 2 images' : 'Upload image to edit, up to 2 images'}
+
+ )}
+
+
fileInputRef.current?.click()}
+ className="w-full"
+ disabled={
+ (selectedTool === 'generate' && uploadedImages.length >= 2) ||
+ (selectedTool === 'edit' && editReferenceImages.length >= 2)
+ }
+ >
+
+ Upload
+
+
+ {/* Show uploaded images preview */}
+ {((selectedTool === 'generate' && uploadedImages.length > 0) ||
+ (selectedTool === 'edit' && editReferenceImages.length > 0)) && (
+
+ {(selectedTool === 'generate' ? uploadedImages : editReferenceImages).map((image, index) => (
+
+
+
selectedTool === 'generate' ? removeUploadedImage(index) : removeEditReferenceImage(index)}
+ className="absolute top-1 right-1 bg-gray-900/80 text-gray-400 hover:text-gray-200 rounded-full p-1 transition-colors"
+ >
+ ×
+
+
+ Ref {index + 1}
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Prompt Input */}
+
+
+ {selectedTool === 'generate' ? 'Describe what you want to create' : 'Describe your changes'}
+
+
+
+
+ {/* Generate Button */}
+
+ {isGenerating ? (
+ <>
+
+ Generating...
+ >
+ ) : (
+ <>
+
+ {selectedTool === 'generate' ? 'Generate' : 'Apply Edit'}
+ >
+ )}
+
+
+ {/* Advanced Controls */}
+
+
setShowAdvanced(!showAdvanced)}
+ className="flex items-center text-sm text-gray-400 hover:text-gray-300 transition-colors duration-200"
+ >
+ {showAdvanced ? : }
+ {showAdvanced ? 'Hide' : 'Show'} Advanced Controls
+
+
+
setShowClearConfirm(!showClearConfirm)}
+ className="flex items-center text-sm text-gray-400 hover:text-red-400 transition-colors duration-200 mt-2"
+ >
+
+ Clear Session
+
+
+ {showClearConfirm && (
+
+
+ Are you sure you want to clear this session? This will remove all uploads, prompts, and canvas content.
+
+
+
+ Yes, Clear
+
+ setShowClearConfirm(false)}
+ className="flex-1"
+ >
+ Cancel
+
+
+
+ )}
+
+ {showAdvanced && (
+
+ )}
+
+
+ {/* Keyboard Shortcuts */}
+
+
Shortcuts
+
+
+ Generate
+ ⌘ + Enter
+
+
+ Re-roll
+ ⇧ + R
+
+
+ Edit mode
+ E
+
+
+ History
+ H
+
+
+ Toggle Panel
+ P
+
+
+
+
+ {/* Prompt Hints Modal */}
+
+ >
+ );
+};
\ No newline at end of file
diff --git a/src/components/PromptHints.tsx b/src/components/PromptHints.tsx
new file mode 100644
index 0000000..a10e57e
--- /dev/null
+++ b/src/components/PromptHints.tsx
@@ -0,0 +1,87 @@
+import React from 'react';
+import * as Dialog from '@radix-ui/react-dialog';
+import { X } from 'lucide-react';
+import { PromptHint } from '../types';
+import { Button } from './ui/Button';
+
+const promptHints: PromptHint[] = [
+ {
+ category: 'subject',
+ text: 'Be specific about the main subject',
+ example: '"A vintage red bicycle" vs "bicycle"'
+ },
+ {
+ category: 'scene',
+ text: 'Describe the environment and setting',
+ example: '"in a cobblestone alley during golden hour"'
+ },
+ {
+ category: 'action',
+ text: 'Include movement or activity',
+ example: '"cyclist pedaling through puddles"'
+ },
+ {
+ category: 'style',
+ text: 'Specify artistic style or mood',
+ example: '"cinematic photography, moody lighting"'
+ },
+ {
+ category: 'camera',
+ text: 'Add camera perspective details',
+ example: '"shot with 85mm lens, shallow depth of field"'
+ }
+];
+
+const categoryColors = {
+ subject: 'bg-blue-500/10 border-blue-500/30 text-blue-400',
+ scene: 'bg-green-500/10 border-green-500/30 text-green-400',
+ action: 'bg-purple-500/10 border-purple-500/30 text-purple-400',
+ style: 'bg-orange-500/10 border-orange-500/30 text-orange-400',
+ camera: 'bg-pink-500/10 border-pink-500/30 text-pink-400',
+};
+
+interface PromptHintsProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export const PromptHints: React.FC = ({ open, onOpenChange }) => {
+ return (
+
+
+
+
+
+
+ Prompt Quality Tips
+
+
+
+
+
+
+
+
+
+ {promptHints.map((hint, index) => (
+
+
+ {hint.category}
+
+
{hint.text}
+
{hint.example}
+
+ ))}
+
+
+
+ Best practice: Write full sentences that describe the complete scene,
+ not just keywords. Think "paint me a picture with words."
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
new file mode 100644
index 0000000..c6ffaf8
--- /dev/null
+++ b/src/components/ui/Button.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { cn } from '../../utils/cn';
+
+const buttonVariants = cva(
+ 'inline-flex items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
+ {
+ variants: {
+ variant: {
+ default: 'bg-yellow-400 text-gray-900 hover:bg-yellow-300 focus-visible:ring-yellow-400',
+ secondary: 'bg-gray-800 text-gray-100 hover:bg-gray-700 focus-visible:ring-gray-600',
+ outline: 'border border-gray-600 bg-transparent text-gray-300 hover:bg-gray-800 hover:text-gray-100',
+ ghost: 'text-gray-400 hover:bg-gray-800 hover:text-gray-100',
+ destructive: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500',
+ },
+ size: {
+ default: 'h-10 px-4 py-2',
+ sm: 'h-8 px-3 text-xs',
+ lg: 'h-12 px-8',
+ icon: 'h-10 w-10',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ }
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {}
+
+export const Button = React.forwardRef(
+ ({ className, variant, size, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+
+Button.displayName = 'Button';
\ No newline at end of file
diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx
new file mode 100644
index 0000000..89f0fc5
--- /dev/null
+++ b/src/components/ui/Input.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { cn } from '../../utils/cn';
+
+export interface InputProps extends React.InputHTMLAttributes {}
+
+export const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+
+Input.displayName = 'Input';
\ No newline at end of file
diff --git a/src/components/ui/Textarea.tsx b/src/components/ui/Textarea.tsx
new file mode 100644
index 0000000..d6dad4c
--- /dev/null
+++ b/src/components/ui/Textarea.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import { cn } from '../../utils/cn';
+
+export interface TextareaProps extends React.TextareaHTMLAttributes {}
+
+export const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+
+Textarea.displayName = 'Textarea';
\ No newline at end of file
diff --git a/src/hooks/useImageGeneration.ts b/src/hooks/useImageGeneration.ts
new file mode 100644
index 0000000..7bf8cb9
--- /dev/null
+++ b/src/hooks/useImageGeneration.ts
@@ -0,0 +1,274 @@
+import { useMutation } from '@tanstack/react-query';
+import { geminiService, GenerationRequest, EditRequest } from '../services/geminiService';
+import { useAppStore } from '../store/useAppStore';
+import { generateId } from '../utils/imageUtils';
+import { Generation, Edit, Asset } from '../types';
+
+export const useImageGeneration = () => {
+ const { addGeneration, setIsGenerating, setCanvasImage, setCurrentProject, currentProject } = useAppStore();
+
+ const generateMutation = useMutation({
+ mutationFn: async (request: GenerationRequest) => {
+ const images = await geminiService.generateImage(request);
+ return images;
+ },
+ onMutate: () => {
+ setIsGenerating(true);
+ },
+ onSuccess: (images, request) => {
+ if (images.length > 0) {
+ const outputAssets: Asset[] = images.map((base64, index) => ({
+ id: generateId(),
+ type: 'output',
+ url: `data:image/png;base64,${base64}`,
+ mime: 'image/png',
+ width: 1024, // Default Gemini output size
+ height: 1024,
+ checksum: base64.slice(0, 32) // Simple checksum
+ }));
+
+ const generation: Generation = {
+ id: generateId(),
+ prompt: request.prompt,
+ parameters: {
+ aspectRatio: '1:1',
+ seed: request.seed,
+ temperature: request.temperature
+ },
+ sourceAssets: request.referenceImage ? [{
+ id: generateId(),
+ type: 'original',
+ url: `data:image/png;base64,${request.referenceImages[0]}`,
+ mime: 'image/png',
+ width: 1024,
+ height: 1024,
+ checksum: request.referenceImages[0].slice(0, 32)
+ }] : request.referenceImages ? request.referenceImages.map((img, index) => ({
+ id: generateId(),
+ type: 'original' as const,
+ url: `data:image/png;base64,${img}`,
+ mime: 'image/png',
+ width: 1024,
+ height: 1024,
+ checksum: img.slice(0, 32)
+ })) : [],
+ outputAssets,
+ modelVersion: 'gemini-2.5-flash-image-preview',
+ timestamp: Date.now()
+ };
+
+ addGeneration(generation);
+ setCanvasImage(outputAssets[0].url);
+
+ // Create project if none exists
+ if (!currentProject) {
+ const newProject = {
+ id: generateId(),
+ title: 'Untitled Project',
+ generations: [generation],
+ edits: [],
+ createdAt: Date.now(),
+ updatedAt: Date.now()
+ };
+ setCurrentProject(newProject);
+ }
+ }
+ setIsGenerating(false);
+ },
+ onError: (error) => {
+ console.error('Generation failed:', error);
+ setIsGenerating(false);
+ }
+ });
+
+ return {
+ generate: generateMutation.mutate,
+ isGenerating: generateMutation.isPending,
+ error: generateMutation.error
+ };
+};
+
+export const useImageEditing = () => {
+ const {
+ addEdit,
+ setIsGenerating,
+ setCanvasImage,
+ canvasImage,
+ editReferenceImages,
+ brushStrokes,
+ selectedGenerationId,
+ currentProject,
+ seed,
+ temperature
+ } = useAppStore();
+
+ const editMutation = useMutation({
+ mutationFn: async (instruction: string) => {
+ // Always use canvas image as primary target if available, otherwise use first uploaded image
+ const sourceImage = canvasImage || uploadedImages[0];
+ if (!sourceImage) throw new Error('No image to edit');
+
+ // Convert canvas image to base64
+ const base64Image = sourceImage.includes('base64,')
+ ? sourceImage.split('base64,')[1]
+ : sourceImage;
+
+ // Get reference images for style guidance
+ let referenceImages = editReferenceImages
+ .filter(img => img.includes('base64,'))
+ .map(img => img.split('base64,')[1]);
+
+ let maskImage: string | undefined;
+ let maskedReferenceImage: string | undefined;
+
+ // Create mask from brush strokes if any exist
+ if (brushStrokes.length > 0) {
+ // Create a temporary image to get actual dimensions
+ const tempImg = new Image();
+ tempImg.src = sourceImage;
+ await new Promise((resolve) => {
+ tempImg.onload = () => resolve();
+ });
+
+ // Create mask canvas with exact image dimensions
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d')!;
+ canvas.width = tempImg.width;
+ canvas.height = tempImg.height;
+
+ // Fill with black (unmasked areas)
+ ctx.fillStyle = 'black';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ // Draw white strokes (masked areas)
+ ctx.strokeStyle = 'white';
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+
+ brushStrokes.forEach(stroke => {
+ if (stroke.points.length >= 4) {
+ ctx.lineWidth = stroke.brushSize;
+ ctx.beginPath();
+ ctx.moveTo(stroke.points[0], stroke.points[1]);
+
+ for (let i = 2; i < stroke.points.length; i += 2) {
+ ctx.lineTo(stroke.points[i], stroke.points[i + 1]);
+ }
+ ctx.stroke();
+ }
+ });
+
+ // Convert mask to base64
+ const maskDataUrl = canvas.toDataURL('image/png');
+ maskImage = maskDataUrl.split('base64,')[1];
+
+ // Create masked reference image (original image with mask overlay)
+ const maskedCanvas = document.createElement('canvas');
+ const maskedCtx = maskedCanvas.getContext('2d')!;
+ maskedCanvas.width = tempImg.width;
+ maskedCanvas.height = tempImg.height;
+
+ // Draw original image
+ maskedCtx.drawImage(tempImg, 0, 0);
+
+ // Draw mask overlay with transparency
+ maskedCtx.globalCompositeOperation = 'source-over';
+ maskedCtx.globalAlpha = 0.4;
+ maskedCtx.fillStyle = '#A855F7';
+
+ brushStrokes.forEach(stroke => {
+ if (stroke.points.length >= 4) {
+ maskedCtx.lineWidth = stroke.brushSize;
+ maskedCtx.strokeStyle = '#A855F7';
+ maskedCtx.lineCap = 'round';
+ maskedCtx.lineJoin = 'round';
+ maskedCtx.beginPath();
+ maskedCtx.moveTo(stroke.points[0], stroke.points[1]);
+
+ for (let i = 2; i < stroke.points.length; i += 2) {
+ maskedCtx.lineTo(stroke.points[i], stroke.points[i + 1]);
+ }
+ maskedCtx.stroke();
+ }
+ });
+
+ maskedCtx.globalAlpha = 1;
+ maskedCtx.globalCompositeOperation = 'source-over';
+
+ const maskedDataUrl = maskedCanvas.toDataURL('image/png');
+ maskedReferenceImage = maskedDataUrl.split('base64,')[1];
+
+ // Add the masked image as a reference for the model
+ referenceImages = [maskedReferenceImage, ...referenceImages];
+ }
+
+ const request: EditRequest = {
+ instruction,
+ originalImage: base64Image,
+ referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
+ maskImage,
+ temperature,
+ seed
+ };
+
+ const images = await geminiService.editImage(request);
+ return { images, maskedReferenceImage };
+ },
+ onMutate: () => {
+ setIsGenerating(true);
+ },
+ onSuccess: ({ images, maskedReferenceImage }, instruction) => {
+ if (images.length > 0) {
+ const outputAssets: Asset[] = images.map((base64, index) => ({
+ id: generateId(),
+ type: 'output',
+ url: `data:image/png;base64,${base64}`,
+ mime: 'image/png',
+ width: 1024,
+ height: 1024,
+ checksum: base64.slice(0, 32)
+ }));
+
+ // Create mask reference asset if we have one
+ const maskReferenceAsset: Asset | undefined = maskedReferenceImage ? {
+ id: generateId(),
+ type: 'mask',
+ url: `data:image/png;base64,${maskedReferenceImage}`,
+ mime: 'image/png',
+ width: 1024,
+ height: 1024,
+ checksum: maskedReferenceImage.slice(0, 32)
+ } : undefined;
+
+ const edit: Edit = {
+ id: generateId(),
+ parentGenerationId: selectedGenerationId || (currentProject?.generations[currentProject.generations.length - 1]?.id || ''),
+ maskAssetId: brushStrokes.length > 0 ? generateId() : undefined,
+ maskReferenceAsset,
+ instruction,
+ outputAssets,
+ timestamp: Date.now()
+ };
+
+ addEdit(edit);
+
+ // Automatically load the edited image in the canvas
+ const { selectEdit, selectGeneration } = useAppStore.getState();
+ setCanvasImage(outputAssets[0].url);
+ selectEdit(edit.id);
+ selectGeneration(null);
+ }
+ setIsGenerating(false);
+ },
+ onError: (error) => {
+ console.error('Edit failed:', error);
+ setIsGenerating(false);
+ }
+ });
+
+ return {
+ edit: editMutation.mutate,
+ isEditing: editMutation.isPending,
+ error: editMutation.error
+ };
+};
\ No newline at end of file
diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts
new file mode 100644
index 0000000..4b0214c
--- /dev/null
+++ b/src/hooks/useKeyboardShortcuts.ts
@@ -0,0 +1,63 @@
+import { useEffect } from 'react';
+import { useAppStore } from '../store/useAppStore';
+
+export const useKeyboardShortcuts = () => {
+ const {
+ setSelectedTool,
+ setShowHistory,
+ showHistory,
+ setShowPromptPanel,
+ showPromptPanel,
+ currentPrompt,
+ isGenerating
+ } = useAppStore();
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ // Ignore if user is typing in an input
+ if (event.target instanceof HTMLInputElement ||
+ event.target instanceof HTMLTextAreaElement) {
+ // Only handle Cmd/Ctrl + Enter for generation
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
+ event.preventDefault();
+ if (!isGenerating && currentPrompt.trim()) {
+ console.log('Generate via keyboard shortcut');
+ }
+ }
+ return;
+ }
+
+ switch (event.key.toLowerCase()) {
+ case 'e':
+ event.preventDefault();
+ setSelectedTool('edit');
+ break;
+ case 'g':
+ event.preventDefault();
+ setSelectedTool('generate');
+ break;
+ case 'm':
+ event.preventDefault();
+ setSelectedTool('mask');
+ break;
+ case 'h':
+ event.preventDefault();
+ setShowHistory(!showHistory);
+ break;
+ case 'p':
+ event.preventDefault();
+ setShowPromptPanel(!showPromptPanel);
+ break;
+ case 'r':
+ if (event.shiftKey) {
+ event.preventDefault();
+ console.log('Re-roll variants');
+ }
+ break;
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [setSelectedTool, setShowHistory, showHistory, setShowPromptPanel, showPromptPanel, currentPrompt, isGenerating]);
+};
\ No newline at end of file
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..5468e83
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,77 @@
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Inter', sans-serif;
+ font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
+ overflow: hidden;
+}
+
+/* Custom scrollbar for dark theme */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: rgb(17 24 39);
+}
+
+::-webkit-scrollbar-thumb {
+ background: rgb(75 85 99);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: rgb(107 114 128);
+}
+
+/* Custom range slider styling */
+.slider::-webkit-slider-thumb {
+ appearance: none;
+ height: 16px;
+ width: 16px;
+ border-radius: 50%;
+ background: #FDE047;
+ cursor: pointer;
+ border: 2px solid #1F2937;
+}
+
+.slider::-moz-range-thumb {
+ height: 16px;
+ width: 16px;
+ border-radius: 50%;
+ background: #FDE047;
+ cursor: pointer;
+ border: 2px solid #1F2937;
+}
+
+/* Marching ants animation */
+@keyframes marching-ants {
+ 0% { stroke-dashoffset: 0; }
+ 100% { stroke-dashoffset: 10; }
+}
+
+.marching-ants {
+ stroke-dasharray: 5 5;
+ animation: marching-ants 0.5s linear infinite;
+}
+
+/* Focus styles for accessibility */
+*:focus-visible {
+ outline: 2px solid #FDE047;
+ outline-offset: 2px;
+}
+
+/* Smooth transitions */
+* {
+ transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
+ transition-duration: 200ms;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+}
\ No newline at end of file
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..ea9e363
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import App from './App.tsx';
+import './index.css';
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+);
diff --git a/src/services/cacheService.ts b/src/services/cacheService.ts
new file mode 100644
index 0000000..ed732f1
--- /dev/null
+++ b/src/services/cacheService.ts
@@ -0,0 +1,71 @@
+import { get, set, del, keys } from 'idb-keyval';
+import { Project, Generation, Asset } from '../types';
+
+const CACHE_PREFIX = 'nano-banana';
+const CACHE_VERSION = '1.0';
+
+export class CacheService {
+ private static getKey(type: string, id: string): string {
+ return `${CACHE_PREFIX}-${CACHE_VERSION}-${type}-${id}`;
+ }
+
+ // Project caching
+ static async saveProject(project: Project): Promise {
+ await set(this.getKey('project', project.id), project);
+ }
+
+ static async getProject(id: string): Promise {
+ return (await get(this.getKey('project', id))) || null;
+ }
+
+ static async getAllProjects(): Promise {
+ const allKeys = await keys();
+ const projectKeys = allKeys.filter(key =>
+ typeof key === 'string' && key.includes(`${CACHE_PREFIX}-${CACHE_VERSION}-project-`)
+ );
+
+ const projects = await Promise.all(
+ projectKeys.map(key => get(key as string))
+ );
+
+ return projects.filter(Boolean) as Project[];
+ }
+
+ // Asset caching (for offline access)
+ static async cacheAsset(asset: Asset, data: Blob): Promise {
+ await set(this.getKey('asset', asset.id), {
+ asset,
+ data,
+ cachedAt: Date.now()
+ });
+ }
+
+ static async getCachedAsset(assetId: string): Promise<{ asset: Asset; data: Blob } | null> {
+ const cached = await get(this.getKey('asset', assetId));
+ return cached || null;
+ }
+
+ // Generation metadata caching
+ static async cacheGeneration(generation: Generation): Promise {
+ await set(this.getKey('generation', generation.id), generation);
+ }
+
+ static async getGeneration(id: string): Promise {
+ return (await get(this.getKey('generation', id))) || null;
+ }
+
+ // Clear old cache entries
+ static async clearOldCache(maxAge: number = 7 * 24 * 60 * 60 * 1000): Promise {
+ const allKeys = await keys();
+ const now = Date.now();
+
+ for (const key of allKeys) {
+ if (typeof key === 'string' && key.startsWith(CACHE_PREFIX)) {
+ const cached = await get(key);
+ if (cached?.cachedAt && (now - cached.cachedAt) > maxAge) {
+ await del(key);
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/services/geminiService.ts b/src/services/geminiService.ts
new file mode 100644
index 0000000..68655ac
--- /dev/null
+++ b/src/services/geminiService.ts
@@ -0,0 +1,169 @@
+import { GoogleGenAI } from '@google/genai';
+
+// Note: In production, this should be handled via a backend proxy
+const API_KEY = import.meta.env.VITE_GEMINI_API_KEY || 'demo-key';
+const genAI = new GoogleGenAI({ apiKey: API_KEY });
+
+export interface GenerationRequest {
+ prompt: string;
+ referenceImages?: string[]; // base64 array
+ temperature?: number;
+ seed?: number;
+}
+
+export interface EditRequest {
+ instruction: string;
+ originalImage: string; // base64
+ referenceImages?: string[]; // base64 array
+ maskImage?: string; // base64
+ temperature?: number;
+ seed?: number;
+}
+
+export interface SegmentationRequest {
+ image: string; // base64
+ query: string; // "the object at pixel (x,y)" or "the red car"
+}
+
+export class GeminiService {
+ async generateImage(request: GenerationRequest): Promise {
+ try {
+ const contents: any[] = [{ text: request.prompt }];
+
+ // Add reference images if provided
+ if (request.referenceImages && request.referenceImages.length > 0) {
+ request.referenceImages.forEach(image => {
+ contents.push({
+ inlineData: {
+ mimeType: "image/png",
+ data: image,
+ },
+ });
+ });
+ }
+
+ const response = await genAI.models.generateContent({
+ model: "gemini-2.5-flash-image-preview",
+ contents,
+ });
+
+ const images: string[] = [];
+
+ for (const part of response.candidates[0].content.parts) {
+ if (part.inlineData) {
+ images.push(part.inlineData.data);
+ }
+ }
+
+ return images;
+ } catch (error) {
+ console.error('Error generating image:', error);
+ throw new Error('Failed to generate image. Please try again.');
+ }
+ }
+
+ async editImage(request: EditRequest): Promise {
+ try {
+ const contents = [
+ { text: this.buildEditPrompt(request) },
+ {
+ inlineData: {
+ mimeType: "image/png",
+ data: request.originalImage,
+ },
+ },
+ ];
+
+ // Add reference images if provided
+ if (request.referenceImages && request.referenceImages.length > 0) {
+ request.referenceImages.forEach(image => {
+ contents.push({
+ inlineData: {
+ mimeType: "image/png",
+ data: image,
+ },
+ });
+ });
+ }
+
+ if (request.maskImage) {
+ contents.push({
+ inlineData: {
+ mimeType: "image/png",
+ data: request.maskImage,
+ },
+ });
+ }
+
+ const response = await genAI.models.generateContent({
+ model: "gemini-2.5-flash-image-preview",
+ contents,
+ });
+
+ const images: string[] = [];
+
+ for (const part of response.candidates[0].content.parts) {
+ if (part.inlineData) {
+ images.push(part.inlineData.data);
+ }
+ }
+
+ return images;
+ } catch (error) {
+ console.error('Error editing image:', error);
+ throw new Error('Failed to edit image. Please try again.');
+ }
+ }
+
+ async segmentImage(request: SegmentationRequest): Promise {
+ try {
+ const prompt = [
+ { text: `Analyze this image and create a segmentation mask for: ${request.query}
+
+Return a JSON object with this exact structure:
+{
+ "masks": [
+ {
+ "label": "description of the segmented object",
+ "box_2d": [x, y, width, height],
+ "mask": "base64-encoded binary mask image"
+ }
+ ]
+}
+
+Only segment the specific object or region requested. The mask should be a binary PNG where white pixels (255) indicate the selected region and black pixels (0) indicate the background.` },
+ {
+ inlineData: {
+ mimeType: "image/png",
+ data: request.image,
+ },
+ },
+ ];
+
+ const response = await genAI.models.generateContent({
+ model: "gemini-2.5-flash-image-preview",
+ contents: prompt,
+ });
+
+ const responseText = response.candidates[0].content.parts[0].text;
+ return JSON.parse(responseText);
+ } catch (error) {
+ console.error('Error segmenting image:', error);
+ throw new Error('Failed to segment image. Please try again.');
+ }
+ }
+
+ private buildEditPrompt(request: EditRequest): string {
+ const maskInstruction = request.maskImage
+ ? "\n\nIMPORTANT: Apply changes ONLY where the mask image shows white pixels (value 255). Leave all other areas completely unchanged. Respect the mask boundaries precisely and maintain seamless blending at the edges."
+ : "";
+
+ return `Edit this image according to the following instruction: ${request.instruction}
+
+Maintain the original image's lighting, perspective, and overall composition. Make the changes look natural and seamlessly integrated.${maskInstruction}
+
+Preserve image quality and ensure the edit looks professional and realistic.`;
+ }
+}
+
+export const geminiService = new GeminiService();
\ No newline at end of file
diff --git a/src/services/imageProcessing.ts b/src/services/imageProcessing.ts
new file mode 100644
index 0000000..7569546
--- /dev/null
+++ b/src/services/imageProcessing.ts
@@ -0,0 +1,95 @@
+import { SegmentationMask } from '../types';
+import { generateId } from '../utils/imageUtils';
+
+export class ImageProcessor {
+ // Interactive segmentation using click point
+ static async createMaskFromClick(
+ image: HTMLImageElement,
+ x: number,
+ y: number
+ ): Promise {
+ // Simulate mask creation - in production this would use MediaPipe
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d')!;
+ canvas.width = image.width;
+ canvas.height = image.height;
+
+ // Draw the image
+ ctx.drawImage(image, 0, 0);
+
+ // Create a simple circular mask for demo
+ const radius = 50;
+ const maskCanvas = document.createElement('canvas');
+ const maskCtx = maskCanvas.getContext('2d')!;
+ maskCanvas.width = image.width;
+ maskCanvas.height = image.height;
+
+ // Fill with black (background)
+ maskCtx.fillStyle = 'black';
+ maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
+
+ // Draw white circle (selected region)
+ maskCtx.fillStyle = 'white';
+ maskCtx.beginPath();
+ maskCtx.arc(x, y, radius, 0, 2 * Math.PI);
+ maskCtx.fill();
+
+ const imageData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
+
+ return {
+ id: generateId(),
+ imageData,
+ bounds: {
+ x: Math.max(0, x - radius),
+ y: Math.max(0, y - radius),
+ width: radius * 2,
+ height: radius * 2
+ },
+ feather: 5
+ };
+ }
+
+ // Apply feathering to mask
+ static applyFeathering(mask: SegmentationMask, featherRadius: number): ImageData {
+ const { imageData } = mask;
+ const data = new Uint8ClampedArray(imageData.data);
+
+ // Simple box blur for feathering
+ for (let i = 0; i < featherRadius; i++) {
+ this.boxBlur(data, imageData.width, imageData.height);
+ }
+
+ return new ImageData(data, imageData.width, imageData.height);
+ }
+
+ private static boxBlur(data: Uint8ClampedArray, width: number, height: number) {
+ const temp = new Uint8ClampedArray(data);
+
+ for (let y = 1; y < height - 1; y++) {
+ for (let x = 1; x < width - 1; x++) {
+ const idx = (y * width + x) * 4;
+
+ // Average the alpha channel (mask channel)
+ const sum =
+ temp[idx - 4 + 3] + temp[idx + 3] + temp[idx + 4 + 3] +
+ temp[idx - width * 4 + 3] + temp[idx + 3] + temp[idx + width * 4 + 3] +
+ temp[idx - width * 4 - 4 + 3] + temp[idx - width * 4 + 4 + 3] + temp[idx + width * 4 - 4 + 3];
+
+ data[idx + 3] = sum / 9;
+ }
+ }
+ }
+
+ // Convert ImageData to base64 for API
+ static imageDataToBase64(imageData: ImageData): string {
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d')!;
+ canvas.width = imageData.width;
+ canvas.height = imageData.height;
+
+ ctx.putImageData(imageData, 0, 0);
+
+ const dataUrl = canvas.toDataURL('image/png');
+ return dataUrl.split(',')[1]; // Remove data:image/png;base64, prefix
+ }
+}
\ No newline at end of file
diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts
new file mode 100644
index 0000000..fe4c5fc
--- /dev/null
+++ b/src/store/useAppStore.ts
@@ -0,0 +1,164 @@
+import { create } from 'zustand';
+import { devtools } from 'zustand/middleware';
+import { Project, Generation, Edit, SegmentationMask, BrushStroke } from '../types';
+
+interface AppState {
+ // Current project
+ currentProject: Project | null;
+
+ // Canvas state
+ canvasImage: string | null;
+ canvasZoom: number;
+ canvasPan: { x: number; y: number };
+
+ // Upload state
+ uploadedImages: string[];
+ editReferenceImages: string[];
+
+ // Brush strokes for painting masks
+ brushStrokes: BrushStroke[];
+ brushSize: number;
+ showMasks: boolean;
+
+ // Generation state
+ isGenerating: boolean;
+ currentPrompt: string;
+ temperature: number;
+ seed: number | null;
+
+ // History and variants
+ selectedGenerationId: string | null;
+ selectedEditId: string | null;
+ showHistory: boolean;
+
+ // Panel visibility
+ showPromptPanel: boolean;
+
+ // UI state
+ selectedTool: 'generate' | 'edit' | 'mask';
+
+ // Actions
+ setCurrentProject: (project: Project | null) => void;
+ setCanvasImage: (url: string | null) => void;
+ setCanvasZoom: (zoom: number) => void;
+ setCanvasPan: (pan: { x: number; y: number }) => void;
+
+ addUploadedImage: (url: string) => void;
+ removeUploadedImage: (index: number) => void;
+ clearUploadedImages: () => void;
+
+ addEditReferenceImage: (url: string) => void;
+ removeEditReferenceImage: (index: number) => void;
+ clearEditReferenceImages: () => void;
+
+ addBrushStroke: (stroke: BrushStroke) => void;
+ clearBrushStrokes: () => void;
+ setBrushSize: (size: number) => void;
+ setShowMasks: (show: boolean) => void;
+
+ setIsGenerating: (generating: boolean) => void;
+ setCurrentPrompt: (prompt: string) => void;
+ setTemperature: (temp: number) => void;
+ setSeed: (seed: number | null) => void;
+
+ addGeneration: (generation: Generation) => void;
+ addEdit: (edit: Edit) => void;
+ selectGeneration: (id: string | null) => void;
+ selectEdit: (id: string | null) => void;
+ setShowHistory: (show: boolean) => void;
+
+ setShowPromptPanel: (show: boolean) => void;
+
+ setSelectedTool: (tool: 'generate' | 'edit' | 'mask') => void;
+}
+
+export const useAppStore = create()(
+ devtools(
+ (set, get) => ({
+ // Initial state
+ currentProject: null,
+ canvasImage: null,
+ canvasZoom: 1,
+ canvasPan: { x: 0, y: 0 },
+
+ uploadedImages: [],
+ editReferenceImages: [],
+
+ brushStrokes: [],
+ brushSize: 20,
+ showMasks: true,
+
+ isGenerating: false,
+ currentPrompt: '',
+ temperature: 0.7,
+ seed: null,
+
+ selectedGenerationId: null,
+ selectedEditId: null,
+ showHistory: true,
+
+ showPromptPanel: true,
+
+ selectedTool: 'generate',
+
+ // Actions
+ setCurrentProject: (project) => set({ currentProject: project }),
+ setCanvasImage: (url) => set({ canvasImage: url }),
+ setCanvasZoom: (zoom) => set({ canvasZoom: zoom }),
+ setCanvasPan: (pan) => set({ canvasPan: pan }),
+
+ addUploadedImage: (url) => set((state) => ({
+ uploadedImages: [...state.uploadedImages, url]
+ })),
+ removeUploadedImage: (index) => set((state) => ({
+ uploadedImages: state.uploadedImages.filter((_, i) => i !== index)
+ })),
+ clearUploadedImages: () => set({ uploadedImages: [] }),
+
+ addEditReferenceImage: (url) => set((state) => ({
+ editReferenceImages: [...state.editReferenceImages, url]
+ })),
+ removeEditReferenceImage: (index) => set((state) => ({
+ editReferenceImages: state.editReferenceImages.filter((_, i) => i !== index)
+ })),
+ clearEditReferenceImages: () => set({ editReferenceImages: [] }),
+
+ addBrushStroke: (stroke) => set((state) => ({
+ brushStrokes: [...state.brushStrokes, stroke]
+ })),
+ clearBrushStrokes: () => set({ brushStrokes: [] }),
+ setBrushSize: (size) => set({ brushSize: size }),
+ setShowMasks: (show) => set({ showMasks: show }),
+
+ setIsGenerating: (generating) => set({ isGenerating: generating }),
+ setCurrentPrompt: (prompt) => set({ currentPrompt: prompt }),
+ setTemperature: (temp) => set({ temperature: temp }),
+ setSeed: (seed) => set({ seed: seed }),
+
+ addGeneration: (generation) => set((state) => ({
+ currentProject: state.currentProject ? {
+ ...state.currentProject,
+ generations: [...state.currentProject.generations, generation],
+ updatedAt: Date.now()
+ } : null
+ })),
+
+ addEdit: (edit) => set((state) => ({
+ currentProject: state.currentProject ? {
+ ...state.currentProject,
+ edits: [...state.currentProject.edits, edit],
+ updatedAt: Date.now()
+ } : null
+ })),
+
+ selectGeneration: (id) => set({ selectedGenerationId: id }),
+ selectEdit: (id) => set({ selectedEditId: id }),
+ setShowHistory: (show) => set({ showHistory: show }),
+
+ setShowPromptPanel: (show) => set({ showPromptPanel: show }),
+
+ setSelectedTool: (tool) => set({ selectedTool: tool }),
+ }),
+ { name: 'nano-banana-store' }
+ )
+);
\ No newline at end of file
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..7bf5ee9
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,67 @@
+export interface Asset {
+ id: string;
+ type: 'original' | 'mask' | 'output';
+ url: string;
+ mime: string;
+ width: number;
+ height: number;
+ checksum: string;
+}
+
+export interface Generation {
+ id: string;
+ prompt: string;
+ parameters: {
+ seed?: number;
+ temperature?: number;
+ };
+ sourceAssets: Asset[];
+ outputAssets: Asset[];
+ modelVersion: string;
+ timestamp: number;
+ costEstimate?: number;
+}
+
+export interface Edit {
+ id: string;
+ parentGenerationId: string;
+ maskAssetId?: string;
+ maskReferenceAsset?: Asset;
+ instruction: string;
+ outputAssets: Asset[];
+ timestamp: number;
+}
+
+export interface Project {
+ id: string;
+ title: string;
+ generations: Generation[];
+ edits: Edit[];
+ createdAt: number;
+ updatedAt: number;
+}
+
+export interface SegmentationMask {
+ id: string;
+ imageData: ImageData;
+ bounds: {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ };
+ feather: number;
+}
+
+export interface BrushStroke {
+ id: string;
+ points: number[];
+ brushSize: number;
+ color: string;
+}
+
+export interface PromptHint {
+ category: 'subject' | 'scene' | 'action' | 'style' | 'camera';
+ text: string;
+ example: string;
+}
\ No newline at end of file
diff --git a/src/utils/cn.ts b/src/utils/cn.ts
new file mode 100644
index 0000000..77f4d80
--- /dev/null
+++ b/src/utils/cn.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
\ No newline at end of file
diff --git a/src/utils/imageUtils.ts b/src/utils/imageUtils.ts
new file mode 100644
index 0000000..e24c844
--- /dev/null
+++ b/src/utils/imageUtils.ts
@@ -0,0 +1,63 @@
+export function base64ToBlob(base64: string, mimeType: string = 'image/png'): Blob {
+ const byteCharacters = atob(base64);
+ const byteNumbers = new Array(byteCharacters.length);
+
+ for (let i = 0; i < byteCharacters.length; i++) {
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
+ }
+
+ const byteArray = new Uint8Array(byteNumbers);
+ return new Blob([byteArray], { type: mimeType });
+}
+
+export function blobToBase64(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const result = reader.result as string;
+ const base64 = result.split(',')[1]; // Remove data:image/png;base64, prefix
+ resolve(base64);
+ };
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
+ });
+}
+
+export function createImageFromBase64(base64: string): Promise {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => resolve(img);
+ img.onerror = reject;
+ img.src = `data:image/png;base64,${base64}`;
+ });
+}
+
+export function resizeImageToFit(
+ image: HTMLImageElement,
+ maxWidth: number,
+ maxHeight: number
+): { width: number; height: number } {
+ const ratio = Math.min(maxWidth / image.width, maxHeight / image.height);
+ return {
+ width: image.width * ratio,
+ height: image.height * ratio
+ };
+}
+
+export function generateId(): string {
+ return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+}
+
+export function downloadImage(base64: string, filename: string): void {
+ const blob = base64ToBlob(base64);
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+
+ URL.revokeObjectURL(url);
+}
\ No newline at end of file
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1 @@
+///