|
|
|
|
|
import React, { useState, useRef } from 'react'; |
|
import { ScissorsIcon, SparklesIcon, UploadCloudIcon, DownloadIcon, AlertTriangleIcon } from '../components/icons'; |
|
import Spinner from '../components/Spinner'; |
|
import { AppStatus } from '../types'; |
|
import { editBonsaiWithKontext } from '../services/mcpService'; |
|
import { isAIConfigured } from '../services/geminiService'; |
|
import type { View } from '../types'; |
|
|
|
const VirtualTrimmerView: React.FC<{ setActiveView: (view: View) => void }> = ({ setActiveView }) => { |
|
const [status, setStatus] = useState<AppStatus>(AppStatus.IDLE); |
|
const [image, setImage] = useState<{ preview: string; base64: string } | null>(null); |
|
const [editedImage, setEditedImage] = useState<string | null>(null); |
|
const [prompt, setPrompt] = useState<string>(''); |
|
const [error, setError] = useState<string>(''); |
|
const fileInputRef = useRef<HTMLInputElement>(null); |
|
const aiConfigured = isAIConfigured(); |
|
|
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { |
|
const file = event.target.files?.[0]; |
|
if (file) { |
|
if (file.size > 4 * 1024 * 1024) { |
|
setError("File size exceeds 4MB. Please upload a smaller image."); |
|
return; |
|
} |
|
const reader = new FileReader(); |
|
reader.onloadend = () => { |
|
const base64String = (reader.result as string).split(',')[1]; |
|
setImage({ preview: reader.result as string, base64: base64String }); |
|
setEditedImage(null); |
|
setError(''); |
|
setStatus(AppStatus.IDLE); |
|
}; |
|
reader.onerror = () => setError("Failed to read the file."); |
|
reader.readAsDataURL(file); |
|
} |
|
}; |
|
|
|
const handleGenerate = async () => { |
|
if (!image) { |
|
setError("Please upload an image first."); |
|
return; |
|
} |
|
if (!prompt.trim()) { |
|
setError("Please enter an editing instruction."); |
|
return; |
|
} |
|
|
|
setStatus(AppStatus.ANALYZING); |
|
setError(''); |
|
setEditedImage(null); |
|
|
|
const result = await editBonsaiWithKontext(image.base64, prompt); |
|
|
|
if (result) { |
|
setEditedImage(result); |
|
setStatus(AppStatus.SUCCESS); |
|
} else { |
|
setError('Failed to generate edit. The AI model may be busy or the request could not be completed. Please try again.'); |
|
setStatus(AppStatus.ERROR); |
|
} |
|
}; |
|
|
|
const presetPrompts = [ |
|
"Trim the lowest branch on the left.", |
|
"Remove all dead leaves.", |
|
"Make the apex more rounded.", |
|
"Slightly shorten the longest branch on the right.", |
|
"Make the foliage pads more dense and defined.", |
|
"Remove the small branch growing towards the viewer.", |
|
]; |
|
|
|
return ( |
|
<div className="space-y-8 max-w-7xl mx-auto"> |
|
<header className="text-center"> |
|
<h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3"> |
|
<ScissorsIcon className="w-8 h-8 text-green-600" /> |
|
Virtual Trimmer |
|
</h2> |
|
<p className="mt-4 text-lg leading-8 text-stone-600 max-w-3xl mx-auto"> |
|
Visualize changes to your bonsai before you make a single cut. Describe your edit and let the AI show you the result. |
|
</p> |
|
</header> |
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start"> |
|
{/* Controls */} |
|
<div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-4 lg:col-span-1"> |
|
<div> |
|
<label className="block text-sm font-medium text-stone-900">1. Upload Photo</label> |
|
<div onClick={() => fileInputRef.current?.click()} className="mt-1 flex justify-center p-4 rounded-lg border-2 border-dashed border-stone-300 hover:border-green-600 transition-colors cursor-pointer"> |
|
<div className="text-center"> |
|
{image ? <p className="text-green-700 font-semibold">Image Loaded!</p> : <UploadCloudIcon className="mx-auto h-10 w-10 text-stone-400" />} |
|
<p className="mt-1 text-sm text-stone-600">{image ? 'Click to change image' : 'Click to upload'}</p> |
|
</div> |
|
<input ref={fileInputRef} type="file" className="sr-only" onChange={handleFileChange} accept="image/png, image/jpeg" /> |
|
</div> |
|
</div> |
|
|
|
<div> |
|
<label htmlFor="edit-prompt" className="block text-sm font-medium text-stone-900">2. Describe Your Edit</label> |
|
<textarea |
|
id="edit-prompt" |
|
rows={4} |
|
value={prompt} |
|
onChange={e => setPrompt(e.target.value)} |
|
className="mt-1 block w-full rounded-md border-stone-300 shadow-sm focus:border-green-500 focus:ring-green-500" |
|
placeholder="e.g., 'Trim the top to be more rounded'" |
|
/> |
|
</div> |
|
<div className="space-y-2"> |
|
<p className="text-sm font-medium text-stone-700">Or try a preset edit:</p> |
|
<div className="flex flex-wrap gap-2"> |
|
{presetPrompts.map((p, i) => ( |
|
<button key={i} onClick={() => setPrompt(p)} className="text-xs bg-stone-100 text-stone-700 px-3 py-1 rounded-full hover:bg-green-100 hover:text-green-800 transition-colors"> |
|
{p} |
|
</button> |
|
))} |
|
</div> |
|
</div> |
|
|
|
<button onClick={handleGenerate} disabled={status === AppStatus.ANALYZING || !image || !aiConfigured} className="w-full flex items-center justify-center gap-2 rounded-md bg-green-700 px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-green-600 disabled:bg-stone-400 disabled:cursor-not-allowed"> |
|
<SparklesIcon className="w-5 h-5"/> {status === AppStatus.ANALYZING ? 'Generating Edit...' : '3. Generate Edit'} |
|
</button> |
|
{error && <p className="text-sm text-red-600 mt-2 bg-red-50 p-3 rounded-md">{error}</p>} |
|
{!aiConfigured && ( |
|
<div className="mt-4 p-3 bg-yellow-50 text-yellow-800 rounded-lg border border-yellow-200 text-center"> |
|
<p className="text-sm"> |
|
This experimental feature relies on AI. Please set your Gemini API key in the{' '} |
|
<button onClick={() => setActiveView('settings')} className="font-bold underline hover:text-yellow-900"> |
|
Settings page |
|
</button> |
|
{' '}to enable it. |
|
</p> |
|
</div> |
|
)} |
|
</div> |
|
|
|
{/* Display */} |
|
<div className="bg-white p-4 rounded-xl shadow-lg border border-stone-200 lg:col-span-2"> |
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> |
|
<div className="text-center"> |
|
<h3 className="text-lg font-bold text-stone-800 mb-2">Original</h3> |
|
<div className="aspect-square bg-stone-100 rounded-lg flex items-center justify-center"> |
|
{image ? <img src={image.preview} alt="Original bonsai" className="max-h-full max-w-full object-contain rounded-lg"/> : <p className="text-stone-500">Upload an image to start</p>} |
|
</div> |
|
</div> |
|
<div className="text-center"> |
|
<h3 className="text-lg font-bold text-stone-800 mb-2">Edited</h3> |
|
<div className="aspect-square bg-stone-100 rounded-lg flex items-center justify-center relative"> |
|
{status === AppStatus.ANALYZING && <Spinner text="AI is trimming..." />} |
|
{status === AppStatus.SUCCESS && editedImage && ( |
|
<> |
|
<img src={`data:image/jpeg;base64,${editedImage}`} alt="Edited bonsai" className="max-h-full max-w-full object-contain rounded-lg"/> |
|
<a href={`data:image/jpeg;base64,${editedImage}`} download="edited-bonsai.jpg" className="absolute top-2 right-2 p-2 bg-white/70 rounded-full hover:bg-white transition-colors"> |
|
<DownloadIcon className="w-5 h-5 text-stone-700"/> |
|
</a> |
|
</> |
|
)} |
|
{status !== AppStatus.ANALYZING && !editedImage && <p className="text-stone-500">Your edited image will appear here</p>} |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
); |
|
}; |
|
|
|
export default VirtualTrimmerView; |