Version 1.0 Release

This commit is contained in:
markfulton
2025-08-31 23:42:08 +07:00
parent 5de7d56c45
commit 98797b9385
26 changed files with 2718 additions and 8 deletions

41
src/components/Header.tsx Normal file
View File

@@ -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 (
<>
<header className="h-16 bg-gray-950 border-b border-gray-800 flex items-center justify-between px-6">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<div className="text-2xl">🍌</div>
<h1 className="text-xl font-semibold text-gray-100 hidden md:block">
Nano Banana AI Image Editor
</h1>
<h1 className="text-xl font-semibold text-gray-100 md:hidden">
NB Editor
</h1>
</div>
<div className="text-xs text-gray-500 bg-gray-800 px-2 py-1 rounded">
1.0
</div>
</div>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="icon"
onClick={() => setShowInfoModal(true)}
>
<HelpCircle className="h-5 w-5" />
</Button>
</div>
</header>
<InfoModal open={showInfoModal} onOpenChange={setShowInfoModal} />
</>
);
};

View File

@@ -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 (
<div className="w-8 bg-gray-950 border-l border-gray-800 flex flex-col items-center justify-center">
<button
onClick={() => 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"
>
<div className="flex flex-col space-y-1">
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
</div>
</button>
</div>
);
}
return (
<div className="w-80 bg-gray-950 border-l border-gray-800 p-6 flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-2">
<History className="h-5 w-5 text-gray-400" />
<h3 className="text-sm font-medium text-gray-300">History & Variants</h3>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setShowHistory(!showHistory)}
className="h-6 w-6"
title="Hide History Panel"
>
×
</Button>
</div>
{/* Variants Grid */}
<div className="mb-6 flex-shrink-0">
<h4 className="text-xs font-medium text-gray-400 mb-3">Current Variants</h4>
{generations.length === 0 && edits.length === 0 ? (
<div className="text-center py-8">
<div className="text-4xl mb-2">🖼</div>
<p className="text-sm text-gray-500">No generations yet</p>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
{/* Show generations */}
{generations.slice(-2).map((generation, index) => (
<div
key={generation.id}
className={cn(
'relative aspect-square rounded-lg border-2 cursor-pointer transition-all duration-200 overflow-hidden',
selectedGenerationId === generation.id
? 'border-yellow-400'
: 'border-gray-700 hover:border-gray-600'
)}
onClick={() => {
selectGeneration(generation.id);
if (generation.outputAssets[0]) {
setCanvasImage(generation.outputAssets[0].url);
}
}}
>
{generation.outputAssets[0] ? (
<>
<img
src={generation.outputAssets[0].url}
alt="Generated variant"
className="w-full h-full object-cover"
/>
</>
) : (
<div className="w-full h-full bg-gray-800 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-yellow-400" />
</div>
)}
{/* Variant Number */}
<div className="absolute top-2 left-2 bg-gray-900/80 text-xs px-2 py-1 rounded">
#{index + 1}
</div>
</div>
))}
{/* Show edits */}
{edits.slice(-2).map((edit, index) => (
<div
key={edit.id}
className={cn(
'relative aspect-square rounded-lg border-2 cursor-pointer transition-all duration-200 overflow-hidden',
selectedEditId === edit.id
? 'border-yellow-400'
: 'border-gray-700 hover:border-gray-600'
)}
onClick={() => {
if (edit.outputAssets[0]) {
setCanvasImage(edit.outputAssets[0].url);
selectEdit(edit.id);
selectGeneration(null);
}
}}
>
{edit.outputAssets[0] ? (
<img
src={edit.outputAssets[0].url}
alt="Edited variant"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gray-800 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-yellow-400" />
</div>
)}
{/* Edit Label */}
<div className="absolute top-2 left-2 bg-purple-900/80 text-xs px-2 py-1 rounded">
Edit #{index + 1}
</div>
</div>
))}
</div>
)}
</div>
{/* Current Image Info */}
{(canvasImage || imageDimensions) && (
<div className="mb-4 p-3 bg-gray-900 rounded-lg border border-gray-700">
<h4 className="text-xs font-medium text-gray-400 mb-2">Current Image</h4>
<div className="space-y-1 text-xs text-gray-500">
{imageDimensions && (
<div className="flex justify-between">
<span>Dimensions:</span>
<span className="text-gray-300">{imageDimensions.width} × {imageDimensions.height}</span>
</div>
)}
<div className="flex justify-between">
<span>Mode:</span>
<span className="text-gray-300 capitalize">{selectedTool}</span>
</div>
</div>
</div>
)}
{/* Generation Details */}
<div className="mb-6 p-4 bg-gray-900 rounded-lg border border-gray-700 flex-1 overflow-y-auto min-h-0">
<h4 className="text-xs font-medium text-gray-400 mb-2">Generation Details</h4>
{(() => {
const gen = generations.find(g => g.id === selectedGenerationId);
const selectedEdit = edits.find(e => e.id === selectedEditId);
if (gen) {
return (
<div className="space-y-3">
<div className="space-y-2 text-xs text-gray-500">
<div>
<span className="text-gray-400">Prompt:</span>
<p className="text-gray-300 mt-1">{gen.prompt}</p>
</div>
<div className="flex justify-between">
<span>Model:</span>
<span>{gen.modelVersion}</span>
</div>
{gen.parameters.seed && (
<div className="flex justify-between">
<span>Seed:</span>
<span>{gen.parameters.seed}</span>
</div>
)}
</div>
{/* Reference Images */}
{gen.sourceAssets.length > 0 && (
<div>
<h5 className="text-xs font-medium text-gray-400 mb-2">Reference Images</h5>
<div className="grid grid-cols-2 gap-2">
{gen.sourceAssets.map((asset, index) => (
<button
key={asset.id}
onClick={() => 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"
>
<img
src={asset.url}
alt={`Reference ${index + 1}`}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<ImageIcon className="h-4 w-4 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<div className="absolute bottom-1 left-1 bg-gray-900/80 text-xs px-1 py-0.5 rounded text-gray-300">
Ref {index + 1}
</div>
</button>
))}
</div>
</div>
)}
</div>
);
} else if (selectedEdit) {
const parentGen = generations.find(g => g.id === selectedEdit.parentGenerationId);
return (
<div className="space-y-3">
<div className="space-y-2 text-xs text-gray-500">
<div>
<span className="text-gray-400">Edit Instruction:</span>
<p className="text-gray-300 mt-1">{selectedEdit.instruction}</p>
</div>
<div className="flex justify-between">
<span>Type:</span>
<span>Image Edit</span>
</div>
<div className="flex justify-between">
<span>Created:</span>
<span>{new Date(selectedEdit.timestamp).toLocaleTimeString()}</span>
</div>
{selectedEdit.maskAssetId && (
<div className="flex justify-between">
<span>Mask:</span>
<span className="text-purple-400">Applied</span>
</div>
)}
</div>
{/* Parent Generation Reference */}
{parentGen && (
<div>
<h5 className="text-xs font-medium text-gray-400 mb-2">Original Image</h5>
<button
onClick={() => 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"
>
<img
src={parentGen.outputAssets[0]?.url}
alt="Original"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<ImageIcon className="h-3 w-3 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</button>
</div>
)}
{/* Mask Visualization */}
{selectedEdit.maskReferenceAsset && (
<div>
<h5 className="text-xs font-medium text-gray-400 mb-2">Masked Reference</h5>
<button
onClick={() => 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"
>
<img
src={selectedEdit.maskReferenceAsset.url}
alt="Masked reference"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<ImageIcon className="h-3 w-3 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<div className="absolute bottom-1 left-1 bg-purple-900/80 text-xs px-1 py-0.5 rounded text-purple-300">
Mask
</div>
</button>
</div>
)}
</div>
);
} else {
return (
<div className="space-y-2 text-xs text-gray-500">
<p className="text-gray-400">Select a generation or edit to view details</p>
</div>
);
}
})()}
</div>
{/* Actions */}
<div className="space-y-3 flex-shrink-0">
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
// 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 className="h-4 w-4 mr-2" />
Download
</Button>
</div>
{/* Image Preview Modal */}
<ImagePreviewModal
open={previewModal.open}
onOpenChange={(open) => setPreviewModal(prev => ({ ...prev, open }))}
imageUrl={previewModal.imageUrl}
title={previewModal.title}
description={previewModal.description}
/>
</div>
);
};

View File

@@ -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<any>(null);
const [image, setImage] = useState<HTMLImageElement | null>(null);
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
const [isDrawing, setIsDrawing] = useState(false);
const [currentStroke, setCurrentStroke] = useState<number[]>([]);
// 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 (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="p-3 border-b border-gray-800 bg-gray-950">
<div className="flex items-center justify-between">
{/* Left side - Zoom controls */}
<div className="flex items-center space-x-2">
<Button variant="outline" size="sm" onClick={() => handleZoom(-0.1)}>
<ZoomOut className="h-4 w-4" />
</Button>
<span className="text-sm text-gray-400 min-w-[60px] text-center">
{Math.round(canvasZoom * 100)}%
</span>
<Button variant="outline" size="sm" onClick={() => handleZoom(0.1)}>
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={handleReset}>
<RotateCcw className="h-4 w-4" />
</Button>
</div>
{/* Right side - Tools and actions */}
<div className="flex items-center space-x-2">
{selectedTool === 'mask' && (
<>
<div className="flex items-center space-x-2 mr-2">
<span className="text-xs text-gray-400">Brush:</span>
<input
type="range"
min="5"
max="50"
value={brushSize}
onChange={(e) => setBrushSize(parseInt(e.target.value))}
className="w-16 h-2 bg-gray-800 rounded-lg appearance-none cursor-pointer slider"
/>
<span className="text-xs text-gray-400 w-6">{brushSize}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={clearBrushStrokes}
disabled={brushStrokes.length === 0}
>
<Eraser className="h-4 w-4" />
</Button>
</>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShowMasks(!showMasks)}
className={cn(showMasks && 'bg-yellow-400/10 border-yellow-400/50')}
>
{showMasks ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
<span className="hidden sm:inline ml-2">Masks</span>
</Button>
{canvasImage && (
<Button variant="secondary" size="sm" onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
<span className="hidden sm:inline">Download</span>
</Button>
)}
</div>
</div>
</div>
{/* Canvas Area */}
<div
id="canvas-container"
className="flex-1 relative overflow-hidden bg-gray-800"
>
{!image && !isGenerating && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-6xl mb-4">🍌</div>
<h2 className="text-xl font-medium text-gray-300 mb-2">
Welcome to Nano Banana Framework
</h2>
<p className="text-gray-500 max-w-md">
{selectedTool === 'generate'
? 'Start by describing what you want to create in the prompt box'
: 'Upload an image to begin editing'
}
</p>
</div>
</div>
)}
{isGenerating && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900/50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-yellow-400 mb-4" />
<p className="text-gray-300">Creating your image...</p>
</div>
</div>
)}
<Stage
ref={stageRef}
width={stageSize.width}
height={stageSize.height}
scaleX={canvasZoom}
scaleY={canvasZoom}
x={canvasPan.x * canvasZoom}
y={canvasPan.y * canvasZoom}
draggable={selectedTool !== 'mask'}
onDragEnd={(e) => {
setCanvasPan({
x: e.target.x() / canvasZoom,
y: e.target.y() / canvasZoom
});
}}
onMouseDown={handleMouseDown}
onMousemove={handleMouseMove}
onMouseup={handleMouseUp}
style={{
cursor: selectedTool === 'mask' ? 'crosshair' : 'default'
}}
>
<Layer>
{image && (
<KonvaImage
image={image}
x={(stageSize.width / canvasZoom - image.width) / 2}
y={(stageSize.height / canvasZoom - image.height) / 2}
/>
)}
{/* Brush Strokes */}
{showMasks && brushStrokes.map((stroke) => (
<Line
key={stroke.id}
points={stroke.points}
stroke="#A855F7"
strokeWidth={stroke.brushSize}
tension={0.5}
lineCap="round"
lineJoin="round"
globalCompositeOperation="source-over"
opacity={0.6}
x={(stageSize.width / canvasZoom - (image?.width || 0)) / 2}
y={(stageSize.height / canvasZoom - (image?.height || 0)) / 2}
/>
))}
{/* Current stroke being drawn */}
{isDrawing && currentStroke.length > 2 && (
<Line
points={currentStroke}
stroke="#A855F7"
strokeWidth={brushSize}
tension={0.5}
lineCap="round"
lineJoin="round"
globalCompositeOperation="source-over"
opacity={0.6}
x={(stageSize.width / canvasZoom - (image?.width || 0)) / 2}
y={(stageSize.height / canvasZoom - (image?.height || 0)) / 2}
/>
)}
</Layer>
</Stage>
</div>
{/* Status Bar */}
<div className="p-3 border-t border-gray-800 bg-gray-950">
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center space-x-4">
{brushStrokes.length > 0 && (
<span className="text-yellow-400">{brushStrokes.length} brush stroke{brushStrokes.length !== 1 ? 's' : ''}</span>
)}
</div>
<div className="flex items-center space-x-2">
<span className="text-xs text-gray-500">
© 2025 Mark Fulton -
<a
href="https://www.reinventing.ai/"
target="_blank"
rel="noopener noreferrer"
className="text-yellow-400 hover:text-yellow-300 transition-colors ml-1"
>
Reinventing.AI Solutions
</a>
</span>
<span className="text-gray-600 hidden md:inline"></span>
<span className="text-yellow-400 hidden md:inline"></span>
<span className="hidden md:inline">Powered by Gemini 2.5 Flash Image</span>
</div>
</div>
</div>
</div>
);
};

View File

@@ -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<ImagePreviewModalProps> = ({
open,
onOpenChange,
imageUrl,
title,
description
}) => {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/80 z-50" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-gray-900 border border-gray-700 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto z-50">
<div className="flex items-center justify-between mb-4">
<Dialog.Title className="text-lg font-semibold text-gray-100">
{title}
</Dialog.Title>
<Dialog.Close asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<X className="h-4 w-4" />
</Button>
</Dialog.Close>
</div>
<div className="space-y-4">
{description && (
<p className="text-sm text-gray-400">{description}</p>
)}
<div className="bg-gray-800 rounded-lg p-4">
<img
src={imageUrl}
alt={title}
className="w-full h-auto rounded-lg border border-gray-700"
/>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};

View File

@@ -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<InfoModalProps> = ({ open, onOpenChange }) => {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-50" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-gray-900 border border-gray-700 rounded-lg p-6 w-full max-w-4xl z-50">
<div className="flex items-center justify-between mb-4">
<Dialog.Title className="text-lg font-semibold text-gray-100">
About Nano Banana AI Image Editor
</Dialog.Title>
<Dialog.Close asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<X className="h-4 w-4" />
</Button>
</Dialog.Close>
</div>
<div className="space-y-4">
<div className="space-y-3 text-sm text-gray-300">
<p>
Developed by{' '}
<a
href="https://markfulton.com"
target="_blank"
rel="noopener noreferrer"
className="text-yellow-400 hover:text-yellow-300 transition-colors font-semibold"
>
Mark Fulton
<ExternalLink className="h-3 w-3 inline ml-1" />
</a>
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="p-4 bg-gradient-to-br from-purple-900/30 to-indigo-900/30 rounded-lg border border-purple-500/30">
<div className="flex items-center mb-3">
<Lightbulb className="h-5 w-5 text-purple-400 mr-2" />
<h4 className="text-sm font-semibold text-purple-300">
Learn to Build AI Apps & More Solutions
</h4>
</div>
<p className="text-sm text-gray-300 mb-4">
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.
</p>
<a
href="https://www.reinventing.ai/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-4 py-2 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white rounded-lg transition-all duration-200 font-medium"
>
Join the AI Accelerator Program
<ExternalLink className="h-4 w-4 ml-1" />
</a>
</div>
<div className="p-4 bg-gradient-to-br from-yellow-900/30 to-orange-900/30 rounded-lg border border-yellow-500/30">
<div className="flex items-center mb-3">
<Download className="h-5 w-5 text-yellow-400 mr-2" />
<h4 className="text-sm font-semibold text-yellow-300">
Get a Copy of This App
</h4>
</div>
<p className="text-sm text-gray-300 mb-4">
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.
</p>
<a
href="https://www.skool.com/vibe-coding-is-life/about?ref=456537abaf37491cbcc6976f3c26af41"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-4 py-2 bg-gradient-to-r from-yellow-600 to-orange-600 hover:from-yellow-500 hover:to-orange-500 text-white rounded-lg transition-all duration-200 font-medium"
>
Join Vibe Coding is Life Community
<ExternalLink className="h-4 w-4 ml-1" />
</a>
</div>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};

View File

@@ -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 (
<div className="absolute inset-0 pointer-events-none">
{/* Marching ants effect */}
<div
className="absolute border-2 border-yellow-400 animate-pulse"
style={{
left: selectedMask.bounds.x,
top: selectedMask.bounds.y,
width: selectedMask.bounds.width,
height: selectedMask.bounds.height,
borderStyle: 'dashed',
animationDuration: '1s'
}}
/>
{/* Mask overlay */}
<div
className="absolute bg-yellow-400/20"
style={{
left: selectedMask.bounds.x,
top: selectedMask.bounds.y,
width: selectedMask.bounds.width,
height: selectedMask.bounds.height,
}}
/>
</div>
);
};

View File

@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="w-8 bg-gray-950 border-r border-gray-800 flex flex-col items-center justify-center">
<button
onClick={() => 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"
>
<div className="flex flex-col space-y-1">
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
</div>
</button>
</div>
);
}
return (
<>
<div className="w-80 lg:w-72 xl:w-80 h-full bg-gray-950 border-r border-gray-800 p-6 flex flex-col space-y-6 overflow-y-auto">
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-300">Mode</h3>
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="icon"
onClick={() => setShowHintsModal(true)}
className="h-6 w-6"
>
<HelpCircle className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowPromptPanel(false)}
className="h-6 w-6"
title="Hide Prompt Panel"
>
×
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
{tools.map((tool) => (
<button
key={tool.id}
onClick={() => 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.icon className="h-5 w-5 mb-1" />
<span className="text-xs font-medium">{tool.label}</span>
</button>
))}
</div>
</div>
{/* File Upload */}
<div>
<div>
<label className="text-sm font-medium text-gray-300 mb-1 block">
{selectedTool === 'generate' ? 'Reference Images' : selectedTool === 'edit' ? 'Style References' : 'Upload Image'}
</label>
{selectedTool === 'mask' && (
<p className="text-xs text-gray-400 mb-3">Edit an image with masks</p>
)}
{selectedTool === 'generate' && (
<p className="text-xs text-gray-500 mb-3">Optional, up to 2 images</p>
)}
{selectedTool === 'edit' && (
<p className="text-xs text-gray-500 mb-3">
{canvasImage ? 'Optional style references, up to 2 images' : 'Upload image to edit, up to 2 images'}
</p>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileUpload}
className="hidden"
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
className="w-full"
disabled={
(selectedTool === 'generate' && uploadedImages.length >= 2) ||
(selectedTool === 'edit' && editReferenceImages.length >= 2)
}
>
<Upload className="h-4 w-4 mr-2" />
Upload
</Button>
{/* Show uploaded images preview */}
{((selectedTool === 'generate' && uploadedImages.length > 0) ||
(selectedTool === 'edit' && editReferenceImages.length > 0)) && (
<div className="mt-3 space-y-2">
{(selectedTool === 'generate' ? uploadedImages : editReferenceImages).map((image, index) => (
<div key={index} className="relative">
<img
src={image}
alt={`Reference ${index + 1}`}
className="w-full h-20 object-cover rounded-lg border border-gray-700"
/>
<button
onClick={() => 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"
>
×
</button>
<div className="absolute bottom-1 left-1 bg-gray-900/80 text-xs px-2 py-1 rounded text-gray-300">
Ref {index + 1}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Prompt Input */}
<div>
<label className="text-sm font-medium text-gray-300 mb-3 block">
{selectedTool === 'generate' ? 'Describe what you want to create' : 'Describe your changes'}
</label>
<Textarea
value={currentPrompt}
onChange={(e) => setCurrentPrompt(e.target.value)}
placeholder={
selectedTool === 'generate'
? 'A serene mountain landscape at sunset with a lake reflecting the golden sky...'
: 'Make the sky more dramatic, add storm clouds...'
}
className="min-h-[120px] resize-none"
/>
{/* Prompt Quality Indicator */}
<button
onClick={() => setShowHintsModal(true)}
className="mt-2 flex items-center text-xs hover:text-gray-400 transition-colors group"
>
{currentPrompt.length < 20 ? (
<HelpCircle className="h-3 w-3 mr-2 text-red-500 group-hover:text-red-400" />
) : (
<div className={cn(
'h-2 w-2 rounded-full mr-2',
currentPrompt.length < 50 ? 'bg-yellow-500' : 'bg-green-500'
)} />
)}
<span className="text-gray-500 group-hover:text-gray-400">
{currentPrompt.length < 20 ? 'Add detail for better results' :
currentPrompt.length < 50 ? 'Good detail level' : 'Excellent prompt detail'}
</span>
</button>
</div>
{/* Generate Button */}
<Button
onClick={handleGenerate}
disabled={isGenerating || !currentPrompt.trim()}
className="w-full h-14 text-base font-medium"
>
{isGenerating ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2" />
Generating...
</>
) : (
<>
<Wand2 className="h-4 w-4 mr-2" />
{selectedTool === 'generate' ? 'Generate' : 'Apply Edit'}
</>
)}
</Button>
{/* Advanced Controls */}
<div>
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center text-sm text-gray-400 hover:text-gray-300 transition-colors duration-200"
>
{showAdvanced ? <ChevronDown className="h-4 w-4 mr-1" /> : <ChevronRight className="h-4 w-4 mr-1" />}
{showAdvanced ? 'Hide' : 'Show'} Advanced Controls
</button>
<button
onClick={() => setShowClearConfirm(!showClearConfirm)}
className="flex items-center text-sm text-gray-400 hover:text-red-400 transition-colors duration-200 mt-2"
>
<RotateCcw className="h-4 w-4 mr-2" />
Clear Session
</button>
{showClearConfirm && (
<div className="mt-3 p-3 bg-gray-800 rounded-lg border border-gray-700">
<p className="text-xs text-gray-300 mb-3">
Are you sure you want to clear this session? This will remove all uploads, prompts, and canvas content.
</p>
<div className="flex space-x-2">
<Button
variant="destructive"
size="sm"
onClick={handleClearSession}
className="flex-1"
>
Yes, Clear
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowClearConfirm(false)}
className="flex-1"
>
Cancel
</Button>
</div>
</div>
)}
{showAdvanced && (
<div className="mt-4 space-y-4">
{/* Temperature */}
<div>
<label className="text-xs text-gray-400 mb-2 block">
Creativity ({temperature})
</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
className="w-full h-2 bg-gray-800 rounded-lg appearance-none cursor-pointer slider"
/>
</div>
{/* Seed */}
<div>
<label className="text-xs text-gray-400 mb-2 block">
Seed (optional)
</label>
<input
type="number"
value={seed || ''}
onChange={(e) => setSeed(e.target.value ? parseInt(e.target.value) : null)}
placeholder="Random"
className="w-full h-8 px-2 bg-gray-900 border border-gray-700 rounded text-xs text-gray-100"
/>
</div>
</div>
)}
</div>
{/* Keyboard Shortcuts */}
<div className="pt-4 border-t border-gray-800">
<h4 className="text-xs font-medium text-gray-400 mb-2">Shortcuts</h4>
<div className="space-y-1 text-xs text-gray-500">
<div className="flex justify-between">
<span>Generate</span>
<span> + Enter</span>
</div>
<div className="flex justify-between">
<span>Re-roll</span>
<span> + R</span>
</div>
<div className="flex justify-between">
<span>Edit mode</span>
<span>E</span>
</div>
<div className="flex justify-between">
<span>History</span>
<span>H</span>
</div>
<div className="flex justify-between">
<span>Toggle Panel</span>
<span>P</span>
</div>
</div>
</div>
</div>
{/* Prompt Hints Modal */}
<PromptHints open={showHintsModal} onOpenChange={setShowHintsModal} />
</>
);
};

View File

@@ -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<PromptHintsProps> = ({ open, onOpenChange }) => {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-50" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-gray-900 border border-gray-700 rounded-lg p-6 w-full max-w-md max-h-[80vh] overflow-y-auto z-50">
<div className="flex items-center justify-between mb-4">
<Dialog.Title className="text-lg font-semibold text-gray-100">
Prompt Quality Tips
</Dialog.Title>
<Dialog.Close asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<X className="h-4 w-4" />
</Button>
</Dialog.Close>
</div>
<div className="space-y-4">
{promptHints.map((hint, index) => (
<div key={index} className="space-y-2">
<div className={`inline-block px-2 py-1 rounded text-xs border ${categoryColors[hint.category]}`}>
{hint.category}
</div>
<p className="text-sm text-gray-300">{hint.text}</p>
<p className="text-sm text-gray-500 italic">{hint.example}</p>
</div>
))}
<div className="p-4 bg-gray-800 rounded-lg border border-gray-700 mt-6">
<p className="text-sm text-gray-300">
<strong className="text-yellow-400">Best practice:</strong> Write full sentences that describe the complete scene,
not just keywords. Think "paint me a picture with words."
</p>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};

View File

@@ -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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { cn } from '../../utils/cn';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-lg border border-gray-600 bg-gray-900 px-3 py-2 text-sm text-gray-100 ring-offset-gray-900 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-yellow-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { cn } from '../../utils/cn';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-lg border border-gray-600 bg-gray-900 px-3 py-2 text-sm text-gray-100 ring-offset-gray-900 placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-yellow-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none',
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = 'Textarea';