|
|
|
|
| import React, { useState, useRef } from 'react'; |
| import { FilterIcon, SparklesIcon, UploadCloudIcon, DropletIcon, AlertTriangleIcon } from '../components/icons'; |
| import Spinner from '../components/Spinner'; |
| import { analyzeSoilComposition, isAIConfigured } from '../services/geminiService'; |
| import type { SoilAnalysis, View } from '../types'; |
| import { AppStatus } from '../types'; |
|
|
| const SoilAnalyzerView: 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 [species, setSpecies] = useState<string>(''); |
| const [location, setLocation] = useState<string>(''); |
| const [result, setResult] = useState<SoilAnalysis | null>(null); |
| 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 }); |
| setError(''); |
| setStatus(AppStatus.IDLE); |
| setResult(null); |
| }; |
| reader.onerror = () => setError("Failed to read the file."); |
| reader.readAsDataURL(file); |
| } |
| }; |
|
|
| const handleAnalyze = async () => { |
| if (!image) { |
| setError("Please upload an image of your soil mix."); |
| return; |
| } |
| if (!species.trim()) { |
| setError("Please enter the target species."); |
| return; |
| } |
| if (!location.trim()) { |
| setError("Please enter your location."); |
| return; |
| } |
|
|
| setStatus(AppStatus.ANALYZING); |
| setError(''); |
| setResult(null); |
|
|
| try { |
| const analysisResult = await analyzeSoilComposition(image.base64, species, location); |
| if (analysisResult) { |
| setResult(analysisResult); |
| setStatus(AppStatus.SUCCESS); |
| } else { |
| throw new Error("Could not analyze the soil. The AI may be busy or the image may not be clear enough. Please try again."); |
| } |
| } catch (e: any) { |
| setError(e.message); |
| setStatus(AppStatus.ERROR); |
| } |
| }; |
|
|
| const renderResults = () => { |
| if (!result) return null; |
|
|
| const ratingColors = { |
| Poor: 'bg-red-500', |
| Average: 'bg-yellow-500', |
| Good: 'bg-blue-500', |
| Excellent: 'bg-green-500', |
| Low: 'bg-orange-500', |
| Medium: 'bg-yellow-500', |
| High: 'bg-blue-500' |
| }; |
|
|
| return ( |
| <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-6"> |
| <h3 className="text-xl font-bold text-center text-stone-800">Soil Analysis Results</h3> |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> |
| <div> |
| <h4 className="font-semibold text-stone-800 mb-2">Estimated Composition</h4> |
| <div className="space-y-3"> |
| {result.components.map(comp => ( |
| <div key={comp.name}> |
| <div className="flex justify-between items-center mb-1"> |
| <span className="font-medium text-stone-800">{comp.name}</span> |
| <span className="font-semibold text-amber-800">{comp.percentage}%</span> |
| </div> |
| <div className="w-full bg-stone-200 rounded-full h-2.5"> |
| <div className="bg-amber-600 h-2.5 rounded-full" style={{ width: `${comp.percentage}%` }}></div> |
| </div> |
| </div> |
| ))} |
| </div> |
| </div> |
| <div className="space-y-4"> |
| <h4 className="font-semibold text-stone-800 mb-2">Properties</h4> |
| <div className="flex items-center gap-3"> |
| <strong className="w-32">Drainage:</strong> |
| <span className={`px-3 py-1 text-sm font-medium text-white rounded-full ${ratingColors[result.drainageRating]}`}>{result.drainageRating}</span> |
| </div> |
| <div className="flex items-center gap-3"> |
| <strong className="w-32">Water Retention:</strong> |
| <span className={`px-3 py-1 text-sm font-medium text-white rounded-full ${ratingColors[result.waterRetention]}`}>{result.waterRetention}</span> |
| </div> |
| </div> |
| </div> |
| <div className="mt-4 p-4 bg-stone-50 rounded-lg"> |
| <h5 className="font-semibold text-stone-800">Suitability for {species}</h5> |
| <p className="text-sm text-stone-600 mt-1">{result.suitabilityAnalysis}</p> |
| </div> |
| <div className="mt-4 p-4 bg-green-50 rounded-lg"> |
| <h5 className="font-semibold text-green-800">Improvement Suggestions</h5> |
| <p className="text-sm text-green-700 mt-1">{result.improvementSuggestions}</p> |
| </div> |
| <button onClick={() => setStatus(AppStatus.IDLE)} className="w-full mt-4 bg-stone-200 text-stone-700 font-semibold py-2 px-4 rounded-lg hover:bg-stone-300 transition-colors"> |
| Analyze Another Mix |
| </button> |
| </div> |
| ); |
| } |
| |
| return ( |
| <div className="space-y-8 max-w-3xl 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"> |
| <FilterIcon className="w-8 h-8 text-amber-700" /> |
| Soil Analyzer |
| </h2> |
| <p className="mt-4 text-lg leading-8 text-stone-600"> |
| Take a photo of your bonsai soil to get an AI-powered analysis of its composition and suitability. |
| </p> |
| </header> |
| |
| {status !== AppStatus.SUCCESS && ( |
| <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-4"> |
| <div onClick={() => fileInputRef.current?.click()} className="flex justify-center rounded-lg border-2 border-dashed border-stone-300 px-6 py-10 hover:border-amber-600 transition-colors cursor-pointer"> |
| <div className="text-center"> |
| {image ? <img src={image.preview} alt="Soil preview" className="mx-auto h-40 w-auto rounded-md object-cover" /> : ( |
| <> |
| <UploadCloudIcon className="mx-auto h-12 w-12 text-stone-400" /> |
| <p className="mt-2 text-sm font-semibold text-amber-700">Upload a close-up photo of your soil</p> |
| <p className="text-xs text-stone-500">PNG, JPG up to 4MB</p> |
| </> )} |
| </div> |
| <input ref={fileInputRef} type="file" className="sr-only" onChange={handleFileChange} accept="image/png, image/jpeg" /> |
| </div> |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> |
| <input type="text" value={species} onChange={(e) => setSpecies(e.target.value)} className="block w-full rounded-md border-0 py-2 px-3 text-stone-900 shadow-sm ring-1 ring-inset ring-stone-300 placeholder:text-stone-400 focus:ring-2 focus:ring-inset focus:ring-amber-600" placeholder="Target Species (e.g., Juniper)" /> |
| <input type="text" value={location} onChange={(e) => setLocation(e.target.value)} className="block w-full rounded-md border-0 py-2 px-3 text-stone-900 shadow-sm ring-1 ring-inset ring-stone-300 placeholder:text-stone-400 focus:ring-2 focus:ring-inset focus:ring-amber-600" placeholder="Your Location (e.g., Phoenix, AZ)" /> |
| </div> |
| {error && <p className="text-sm text-red-600">{error}</p>} |
| <button onClick={handleAnalyze} disabled={!image || status === AppStatus.ANALYZING || !aiConfigured} className="w-full mt-2 flex items-center justify-center gap-2 rounded-md bg-amber-700 px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-amber-600 disabled:bg-stone-400 disabled:cursor-not-allowed"> |
| <SparklesIcon className="w-5 h-5"/> {status === AppStatus.ANALYZING ? 'Analyzing...' : 'Analyze Soil'} |
| </button> |
| {!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"> |
| 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 this feature. |
| </p> |
| </div> |
| )} |
| </div> |
| )} |
| |
| {status === AppStatus.ANALYZING && <Spinner text="Yuki is sifting through the details..." />} |
| {status === AppStatus.SUCCESS && renderResults()} |
| {status === AppStatus.ERROR && <p className="text-center text-red-600 p-4 bg-red-50 rounded-md">{error}</p>} |
|
|
| </div> |
| ); |
| }; |
|
|
| export default SoilAnalyzerView; |