Yuki / views /SoilAnalyzerView.tsx
Severian's picture
Upload 43 files
be02369 verified
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) { // 4MB limit
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;