|
|
|
|
| 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; |