Yuki / views /BonsaiDiaryView.tsx
Severian's picture
Upload 43 files
be02369 verified
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { BookUserIcon, PlusCircleIcon, ArrowLeftIcon, HistoryIcon, SparklesIcon, GalleryHorizontalIcon, ImagePlusIcon, Trash2Icon, BugIcon, WrenchIcon, BonsaiIcon, CameraIcon, CalendarIcon, SunIcon, WindIcon, SnowflakeIcon, SunriseIcon, LeafIcon, UmbrellaIcon, AlertTriangleIcon } from '../components/icons';
import type { View, BonsaiTree, DiaryLog, BonsaiAnalysis, SeasonalGuide, ProtectionProfile } from '../types';
import { LogTag } from '../types';
import { analyzeDiaryLog, analyzeGrowthProgression, analyzeFollowUp, generateSeasonalGuide, getProtectionProfile, isAIConfigured } from '../services/geminiService';
import Spinner from '../components/Spinner';
import { useLocalStorage } from '../hooks/useLocalStorage';
import AnalysisDisplay from '../components/AnalysisDisplay';
// --- Main Component ---
const BonsaiDiaryView: React.FC<{ setActiveView: (view: View) => void }> = ({ setActiveView }) => {
const [trees, setTrees] = useLocalStorage<BonsaiTree[]>('bonsai-diary-trees', []);
const [selectedTreeId, setSelectedTreeId] = useState<string | null>(null);
const [isAddTreeModalOpen, setAddTreeModalOpen] = useState(false);
const [newlyAddedId, setNewlyAddedId] = useState<string|null>(null);
const aiConfigured = isAIConfigured();
useEffect(() => {
const newId = window.localStorage.getItem('yuki-bonsai-diary-newly-added-tree-id');
if (newId) {
setNewlyAddedId(newId);
setSelectedTreeId(newId);
window.localStorage.removeItem('yuki-bonsai-diary-newly-added-tree-id');
}
}, []);
const selectedTree = trees.find(t => t.id === selectedTreeId);
const handleAddTree = async (newTreeData: Omit<BonsaiTree, 'id' | 'logs' | 'analysisHistory'>) => {
const newTree: BonsaiTree = {
...newTreeData,
id: `tree-${Date.now()}`,
logs: [],
analysisHistory: [],
};
setTrees(prev => [...prev, newTree]);
// Asynchronously fetch and update the protection profile
if (aiConfigured) {
const profile = await getProtectionProfile(newTree.species);
if(profile) {
const treeWithProfile = { ...newTree, protectionProfile: { ...profile, alertsEnabled: true }};
setTrees(prev => prev.map(t => t.id === newTree.id ? treeWithProfile : t));
}
}
};
const handleUpdateTree = (updatedTree: BonsaiTree) => {
setTrees(prev => prev.map(t => t.id === updatedTree.id ? updatedTree : t));
};
const handleDeleteTree = (treeId: string) => {
if (window.confirm("Are you sure you want to delete this tree and all its logs? This cannot be undone.")) {
setTrees(prev => prev.filter(t => t.id !== treeId));
setSelectedTreeId(null);
}
};
return (
<div className="space-y-8 max-w-6xl 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">
<BookUserIcon className="w-8 h-8 text-blue-600" />
My Garden
</h2>
<p className="mt-4 text-lg leading-8 text-stone-600">
Chronicle the life and growth of your personal trees.
</p>
</header>
{selectedTree ? (
<TreeDetail
tree={selectedTree}
onBack={() => {
setSelectedTreeId(null);
if (newlyAddedId) {
setNewlyAddedId(null);
}
}}
onUpdate={handleUpdateTree}
onDelete={handleDeleteTree}
setActiveView={setActiveView}
showInitialReportOnLoad={newlyAddedId === selectedTree.id}
isAIConfigured={aiConfigured}
/>
) : (
<>
<div className="flex justify-end">
<button onClick={() => setAddTreeModalOpen(true)} className="flex items-center gap-2 rounded-md bg-green-700 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-green-600">
<PlusCircleIcon className="w-5 h-5" />
Add New Tree
</button>
</div>
<TreeList trees={trees} onSelectTree={setSelectedTreeId} />
</>
)}
{isAddTreeModalOpen && (
<Modal onClose={() => setAddTreeModalOpen(false)} title="Add a New Bonsai to Your Garden">
<AddTreeForm
onAddTree={handleAddTree}
onClose={() => setAddTreeModalOpen(false)}
/>
</Modal>
)}
</div>
);
};
// --- Sub-components ---
const TreeList: React.FC<{trees: BonsaiTree[], onSelectTree: (id: string) => void}> = ({ trees, onSelectTree }) => {
if (trees.length === 0) {
return (
<div className="text-center bg-white rounded-xl shadow-md border border-stone-200 p-12">
<BonsaiIcon className="mx-auto h-16 w-16 text-stone-400" />
<h3 className="mt-4 text-xl font-semibold text-stone-800">Your Garden is Empty</h3>
<p className="mt-2 text-stone-600">Click "Add New Tree" or use the "New Tree Analysis" to get started.</p>
</div>
);
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{trees.map(tree => (
<div key={tree.id} className="bg-white rounded-xl shadow-md border border-stone-200 overflow-hidden flex flex-col group cursor-pointer" onClick={() => onSelectTree(tree.id)}>
<img src={`data:image/jpeg;base64,${tree.initialPhoto}`} alt={tree.name} className="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300" />
<div className="p-4 flex-grow flex flex-col">
<h3 className="text-lg font-bold text-stone-900">{tree.name}</h3>
<p className="text-sm text-stone-600">{tree.species}</p>
<div className="mt-auto pt-4 text-xs text-stone-500">
<p>Acquired: {new Date(tree.acquiredDate).toLocaleDateString()}</p>
<p>{tree.logs.length} log entries. {tree.analysisHistory.length} analysis reports.</p>
</div>
</div>
</div>
))}
</div>
);
};
const TreeDetail: React.FC<{tree: BonsaiTree, onBack: () => void, onUpdate: (tree: BonsaiTree) => void, onDelete: (id: string) => void, setActiveView: (view: View) => void, showInitialReportOnLoad: boolean, isAIConfigured: boolean}> = ({ tree, onBack, onUpdate, onDelete, setActiveView, showInitialReportOnLoad, isAIConfigured }) => {
const [isAddLogModalOpen, setAddLogModalOpen] = useState(false);
const [isGrowthModalOpen, setGrowthModalOpen] = useState(false);
const [activeReport, setActiveReport] = useState<BonsaiAnalysis | null>(null);
const [isAnalyzingFollowUp, setIsAnalyzingFollowUp] = useState(false);
const followUpPhotoInputRef = useRef<HTMLInputElement>(null);
const [followUpError, setFollowUpError] = useState('');
const [isSeasonalPlanOpen, setSeasonalPlanOpen] = useState(false);
useEffect(() => {
if (showInitialReportOnLoad && tree.analysisHistory.length > 0) {
setActiveReport(tree.analysisHistory[0].analysis);
}
}, [showInitialReportOnLoad, tree.analysisHistory]);
const handleAddLog = async (newLogData: Omit<DiaryLog, 'id'>, analyze: boolean) => {
let newLog: DiaryLog = { ...newLogData, id: `log-${Date.now()}`};
if (analyze && newLog.photos.length > 0 && isAIConfigured) {
try {
const previousLogWithPhoto = [...tree.logs].reverse().find(l => l.photos.length > 0);
const analysis = await analyzeDiaryLog(tree.species, newLog.photos, previousLogWithPhoto?.photos[0]);
if (analysis) {
newLog.aiAnalysis = analysis;
}
} catch (e: any) {
alert(`Could not get AI analysis: ${e.message}`);
}
}
const updatedTree = { ...tree, logs: [...tree.logs, newLog].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) };
onUpdate(updatedTree);
};
const handleRequestFollowUp = () => {
setFollowUpError('');
followUpPhotoInputRef.current?.click();
};
const handleFollowUpPhotoSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setIsAnalyzingFollowUp(true);
const reader = new FileReader();
reader.onloadend = async () => {
const imageBase64 = (reader.result as string).split(',')[1];
const latestAnalysis = tree.analysisHistory[0]?.analysis;
if (!latestAnalysis) {
setFollowUpError("No initial analysis found. Please use the AI Steward first.");
setIsAnalyzingFollowUp(false);
return;
}
try {
const result = await analyzeFollowUp(imageBase64, latestAnalysis, tree.species, tree.location);
if (result) {
const newAnalysisEntry = { date: new Date().toISOString(), analysis: result };
const updatedTree = { ...tree, analysisHistory: [newAnalysisEntry, ...tree.analysisHistory] };
onUpdate(updatedTree);
setActiveReport(result);
} else {
throw new Error("Failed to get follow-up analysis. Please try again.");
}
} catch (e: any) {
setFollowUpError(e.message);
} finally {
setIsAnalyzingFollowUp(false);
}
};
reader.readAsDataURL(file);
event.target.value = '';
};
return (
<div className="bg-white rounded-xl shadow-lg border border-stone-200 p-6 space-y-6">
<input type="file" ref={followUpPhotoInputRef} onChange={handleFollowUpPhotoSelected} accept="image/*" className="sr-only"/>
<div className="flex justify-between items-start">
<div>
<button onClick={onBack} className="flex items-center gap-2 text-sm font-semibold text-green-700 hover:text-green-600 mb-2">
<ArrowLeftIcon className="w-5 h-5"/> Back to All Trees
</button>
<h3 className="text-3xl font-bold text-stone-900">{tree.name}</h3>
<p className="text-md text-stone-600">{tree.species}</p>
</div>
<div className="flex flex-wrap gap-2 justify-end">
<button onClick={handleRequestFollowUp} disabled={isAnalyzingFollowUp || tree.analysisHistory.length === 0 || !isAIConfigured} className="flex items-center gap-2 text-sm font-semibold bg-purple-100 text-purple-700 hover:bg-purple-200 px-3 py-2 rounded-md transition-colors disabled:bg-stone-200 disabled:text-stone-500 disabled:cursor-not-allowed">
{isAnalyzingFollowUp ? <Spinner text='' /> : <><CameraIcon className="w-4 h-4" /> Request Follow-up</>}
</button>
<button onClick={() => setSeasonalPlanOpen(true)} disabled={!isAIConfigured} className="flex items-center gap-2 text-sm font-semibold bg-indigo-100 text-indigo-700 hover:bg-indigo-200 px-3 py-2 rounded-md transition-colors disabled:bg-stone-200 disabled:text-stone-500 disabled:cursor-not-allowed">
<CalendarIcon className="w-4 h-4"/> View Seasonal Plan
</button>
<button onClick={() => setGrowthModalOpen(true)} className="flex items-center gap-2 text-sm font-semibold bg-blue-100 text-blue-700 hover:bg-blue-200 px-3 py-2 rounded-md transition-colors" disabled={tree.logs.filter(l => l.photos.length > 0).length < 2 || !isAIConfigured}>
<GalleryHorizontalIcon className="w-4 h-4" /> AI Growth Visualizer
</button>
<button onClick={() => setAddLogModalOpen(true)} className="flex items-center gap-2 text-sm font-semibold bg-green-100 text-green-700 hover:bg-green-200 px-3 py-2 rounded-md transition-colors">
<PlusCircleIcon className="w-4 h-4" /> New Log Entry
</button>
<button onClick={() => onDelete(tree.id)} className="flex items-center justify-center bg-red-100 text-red-700 hover:bg-red-200 p-2 rounded-md transition-colors">
<Trash2Icon className="w-4 h-4" />
</button>
</div>
</div>
{!isAIConfigured && (
<div className="p-3 bg-yellow-50 text-yellow-800 rounded-lg border border-yellow-200 text-center text-sm">
Some AI features like Follow-up Analysis and Seasonal Plans are disabled. Please set your Gemini API key in the{' '}
<button onClick={() => setActiveView('settings')} className="font-bold underline hover:text-yellow-900">
Settings page
</button>.
</div>
)}
{followUpError && <p className="text-sm text-red-600 p-3 bg-red-50 rounded-lg text-center">{followUpError}</p>}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-6">
{tree.analysisHistory.length > 0 && (
<div className="bg-stone-50 p-4 rounded-lg border border-stone-200">
<h4 className="text-lg font-semibold text-stone-800 mb-2">Analysis History</h4>
<div className="flex flex-wrap gap-2">
{tree.analysisHistory.map((entry, index) => (
<button key={index} onClick={() => setActiveReport(entry.analysis)} className="text-sm font-medium bg-white border border-stone-300 px-3 py-1.5 rounded-md hover:bg-stone-100 hover:border-stone-400 transition-colors">
Report from {new Date(entry.date).toLocaleDateString()}
</button>
))}
</div>
</div>
)}
<ProtectionSettings tree={tree} onUpdate={onUpdate} isAIConfigured={isAIConfigured} />
</div>
{/* Timeline */}
<div className="relative pl-8 border-l-2 border-stone-100">
<div className="absolute left-0 top-0 -translate-x-1/2 w-0.5 h-full bg-stone-200"></div>
{tree.logs.length > 0 ? tree.logs.map(log => <LogEntry key={log.id} log={log} setActiveView={setActiveView} />) : (
<div className="text-center py-10">
<HistoryIcon className="mx-auto h-12 w-12 text-stone-400" />
<p className="mt-2 text-stone-600">No log entries yet. Add one to start the timeline!</p>
</div>
)}
</div>
</div>
{isAddLogModalOpen && (
<Modal onClose={() => setAddLogModalOpen(false)} title={`New Log for ${tree.name}`}>
<AddLogForm onAddLog={handleAddLog} onClose={() => setAddLogModalOpen(false)} isAIConfigured={isAIConfigured}/>
</Modal>
)}
{isGrowthModalOpen && (
<Modal onClose={() => setGrowthModalOpen(false)} title="AI Growth Visualizer">
<GrowthVisualizer tree={tree} onClose={() => setGrowthModalOpen(false)} />
</Modal>
)}
{activeReport && (
<Modal onClose={() => setActiveReport(null)} title={`Analysis for ${tree.name}`}>
<div className="max-h-[80vh] overflow-y-auto -m-6 p-1">
<div className="p-6">
<AnalysisDisplay analysis={activeReport} isReadonly={true} treeImageBase64={tree.initialPhoto} />
</div>
</div>
</Modal>
)}
{isSeasonalPlanOpen && (
<Modal onClose={() => setSeasonalPlanOpen(false)} title={`Seasonal Plan for ${tree.name}`}>
<SeasonalPlanModal tree={tree} onClose={() => setSeasonalPlanOpen(false)} isAIConfigured={isAIConfigured} />
</Modal>
)}
</div>
)
};
const ProtectionSettings: React.FC<{tree: BonsaiTree, onUpdate: (tree: BonsaiTree) => void, isAIConfigured: boolean}> = ({ tree, onUpdate, isAIConfigured }) => {
const handleProfileChange = (field: keyof ProtectionProfile, value: any) => {
const updatedProfile = {
...tree.protectionProfile,
minTempC: tree.protectionProfile?.minTempC ?? 0,
maxTempC: tree.protectionProfile?.maxTempC ?? 40,
maxWindKph: tree.protectionProfile?.maxWindKph ?? 50,
alertsEnabled: tree.protectionProfile?.alertsEnabled ?? true,
[field]: value,
};
onUpdate({ ...tree, protectionProfile: updatedProfile });
};
if (!tree.protectionProfile) {
return (
<div className="bg-stone-50 p-4 rounded-lg border border-stone-200 text-center">
{isAIConfigured ? <Spinner text="Fetching protection profile..." /> : <p className="text-sm text-stone-500">Protection profile generation disabled. Set API key in Settings.</p>}
</div>
);
}
return (
<div className="bg-stone-50 p-4 rounded-lg border border-stone-200">
<div className="flex justify-between items-center mb-3">
<h4 className="text-lg font-semibold text-stone-800 flex items-center gap-2"><UmbrellaIcon className="w-5 h-5 text-blue-600"/> Weather Shield Settings</h4>
<input type="checkbox" checked={tree.protectionProfile.alertsEnabled} onChange={e => handleProfileChange('alertsEnabled', e.target.checked)} className="h-5 w-5 rounded text-blue-600 focus:ring-blue-500" />
</div>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<label htmlFor="minTemp">Min Temp (°C)</label>
<input type="number" id="minTemp" value={tree.protectionProfile.minTempC} onChange={e => handleProfileChange('minTempC', Number(e.target.value))} className="w-20 p-1 border rounded-md text-center" />
</div>
<div className="flex items-center justify-between">
<label htmlFor="maxTemp">Max Temp (°C)</label>
<input type="number" id="maxTemp" value={tree.protectionProfile.maxTempC} onChange={e => handleProfileChange('maxTempC', Number(e.target.value))} className="w-20 p-1 border rounded-md text-center" />
</div>
<div className="flex items-center justify-between">
<label htmlFor="maxWind">Max Wind (km/h)</label>
<input type="number" id="maxWind" value={tree.protectionProfile.maxWindKph} onChange={e => handleProfileChange('maxWindKph', Number(e.target.value))} className="w-20 p-1 border rounded-md text-center" />
</div>
</div>
</div>
);
};
const LogEntry: React.FC<{log: DiaryLog, setActiveView: (view: View) => void}> = ({log, setActiveView}) => {
const tagLinks: Partial<Record<LogTag, {icon: React.FC<any>, view: View}>> = {
[LogTag.PestControl]: { icon: BugIcon, view: 'pests' },
[LogTag.DiseaseControl]: { icon: BugIcon, view: 'pests' },
[LogTag.Wiring]: { icon: WrenchIcon, view: 'tools' },
[LogTag.Pruning]: { icon: WrenchIcon, view: 'tools' },
[LogTag.Repotting]: { icon: WrenchIcon, view: 'tools' },
};
return (
<div className="relative mb-8 pl-4">
<div className="absolute -left-[9px] top-1.5 w-5 h-5 bg-green-600 rounded-full border-4 border-white"></div>
<p className="text-sm font-semibold text-stone-500">{new Date(log.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
<h4 className="text-lg font-bold text-stone-800">{log.title}</h4>
{log.aiAnalysis && (
<div className="my-3 p-3 bg-green-50 border-l-4 border-green-500 rounded-r-lg">
<div className="flex items-center gap-2 text-green-800 font-bold">
<SparklesIcon className="w-5 h-5" />
<span>Yuki's Analysis</span>
</div>
<p className="text-sm text-green-700 mt-1">{log.aiAnalysis.summary}</p>
{log.aiAnalysis.suggestions && log.aiAnalysis.suggestions.map((s, i) => (
<p key={i} className="text-sm text-green-700 mt-1 italic">Suggestion: {s}</p>
))}
</div>
)}
{log.healthCheckResult && (
<div className="my-3 p-4 bg-amber-50 border-l-4 border-amber-500 rounded-r-lg">
<div className="flex items-center gap-2 text-amber-800 font-bold">
<SparklesIcon className="w-5 h-5" />
<span>Health Check Diagnosis</span>
</div>
<p className="text-sm text-amber-900 mt-1 font-semibold">Probable Cause: {log.healthCheckResult.probableCause} ({log.healthCheckResult.confidence} Confidence)</p>
<p className="text-sm text-amber-800 mt-1">{log.healthCheckResult.explanation}</p>
</div>
)}
<p className="text-stone-600 mt-2 whitespace-pre-wrap">{log.notes}</p>
{log.photos.length > 0 && (
<div className="mt-3 grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{log.photos.map((photo, i) => (
<img key={i} src={`data:image/jpeg;base64,${photo}`} alt={`Log entry ${i+1}`} className="rounded-md object-cover aspect-square" />
))}
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
{log.tags.map(tag => {
const link = tagLinks[tag];
return (
<div key={tag} className="flex items-center bg-stone-100 text-stone-700 text-xs font-medium px-2.5 py-1 rounded-full">
<span>{tag}</span>
{link && (
<button onClick={() => setActiveView(link.view)} className="ml-1.5 p-0.5 rounded-full hover:bg-stone-300" title={`Go to ${link.view}`}>
<link.icon className="w-3 h-3"/>
</button>
)}
</div>
)
})}
</div>
</div>
);
}
const SeasonalPlanModal: React.FC<{ tree: BonsaiTree, onClose: () => void, isAIConfigured: boolean }> = ({ tree, onClose, isAIConfigured }) => {
const [guideData, setGuideData] = useState<SeasonalGuide[] | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const fetchGuide = async () => {
setIsLoading(true);
setError('');
const latestAnalysis = tree.analysisHistory[0]?.analysis;
// Use the guide from the analysis if it exists
if (latestAnalysis && latestAnalysis.seasonalGuide && latestAnalysis.seasonalGuide.length > 0) {
setGuideData(latestAnalysis.seasonalGuide);
setIsLoading(false);
return;
}
// If no cached guide, check if AI is configured before trying to generate
if (!isAIConfigured) {
setError("A seasonal guide was not found in your latest analysis. Please set an API key in Settings to generate a new one.");
setIsLoading(false);
return;
}
// Fallback to generating if not found in analysis
try {
const result = await generateSeasonalGuide(tree.species, tree.location);
if (result) {
setGuideData(result);
} else {
throw new Error("Failed to get seasonal guide from the AI.");
}
} catch (e: any) {
setError(e.message);
} finally {
setIsLoading(false);
}
};
fetchGuide();
}, [tree, isAIConfigured]);
const seasonIcons: { [key: string]: React.FC<React.SVGProps<SVGSVGElement>> } = {
Spring: SunriseIcon,
Summer: SunIcon,
Autumn: WindIcon,
Winter: SnowflakeIcon,
};
return (
<div className="max-h-[70vh] overflow-y-auto pr-2">
{isLoading && <Spinner text="Yuki is reading the almanac..." />}
{error && <p className="text-center text-red-600 p-4 bg-red-50 rounded-md">{error}</p>}
{guideData && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{guideData.sort((a,b) => ['Spring', 'Summer', 'Autumn', 'Winter'].indexOf(a.season) - ['Spring', 'Summer', 'Autumn', 'Winter'].indexOf(b.season)).map(season => {
const Icon = seasonIcons[season.season] || LeafIcon;
return (
<div key={season.season} className="bg-white rounded-xl shadow-md border border-stone-200 p-6">
<div className="flex items-center gap-3 mb-4">
<Icon className="w-7 h-7 text-green-700" />
<h3 className="text-xl font-semibold text-stone-800">{season.season}</h3>
</div>
<div className="space-y-3 text-stone-600">
<p className="italic text-stone-600 mb-4">{season.summary}</p>
<ul className="space-y-2">
{season.tasks.map(task => (
<li key={task.task} className="flex items-center justify-between text-sm">
<span>{task.task}</span>
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${task.importance === 'High' ? 'bg-red-100 text-red-800' : task.importance === 'Medium' ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'}`}>{task.importance}</span>
</li>
))}
</ul>
</div>
</div>
)
})}
</div>
)}
</div>
);
};
// --- Forms and Modals ---
const AddTreeForm: React.FC<{onAddTree: (tree: Omit<BonsaiTree, 'id' | 'logs' | 'analysisHistory'>) => void, onClose: () => void}> = ({onAddTree, onClose}) => {
const [name, setName] = useState('');
const [species, setSpecies] = useState('');
const [location, setLocation] = useState('');
const [acquiredDate, setAcquiredDate] = useState(new Date().toISOString().split('T')[0]);
const [source, setSource] = useState('');
const [initialPhoto, setInitialPhoto] = useState<string>('');
const [notes, setNotes] = useState('');
const [error, setError] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => setInitialPhoto((reader.result as string).split(',')[1]);
reader.readAsDataURL(file);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name || !species || !acquiredDate || !initialPhoto || !location) {
setError('Please fill out all required fields and upload an initial photo.');
return;
}
onAddTree({ name, species, acquiredDate, source, initialPhoto, notes, location });
onClose();
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<input type="text" placeholder="Tree Name (e.g., 'My Juniper')" value={name} onChange={e => setName(e.target.value)} required className="w-full p-2 border rounded-md" />
<input type="text" placeholder="Species (e.g., 'Juniperus Procumbens Nana')" value={species} onChange={e => setSpecies(e.target.value)} required className="w-full p-2 border rounded-md" />
<input type="text" placeholder="Location (e.g., 'San Francisco, CA')" value={location} onChange={e => setLocation(e.target.value)} required className="w-full p-2 border rounded-md" />
<input type="text" placeholder="Source (e.g., 'Local Nursery')" value={source} onChange={e => setSource(e.target.value)} className="w-full p-2 border rounded-md" />
</div>
<input type="date" value={acquiredDate} onChange={e => setAcquiredDate(e.target.value)} required className="w-full p-2 border rounded-md" />
<textarea placeholder="Initial notes..." value={notes} onChange={e => setNotes(e.target.value)} className="w-full p-2 border rounded-md" rows={3}></textarea>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">Initial Photo (Required)</label>
<div onClick={() => fileInputRef.current?.click()} className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-stone-300 border-dashed rounded-md cursor-pointer">
<div className="space-y-1 text-center">
{initialPhoto ? <img src={`data:image/jpeg;base64,${initialPhoto}`} className="mx-auto h-24 w-auto rounded-md"/> : <ImagePlusIcon className="mx-auto h-12 w-12 text-stone-400" />}
<p className="text-sm text-stone-600">Click to upload an image</p>
</div>
</div>
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="sr-only" required/>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="bg-stone-200 text-stone-700 px-4 py-2 rounded-md">Cancel</button>
<button type="submit" className="bg-green-700 text-white px-4 py-2 rounded-md">Add Tree</button>
</div>
</form>
);
};
const AddLogForm: React.FC<{onAddLog: (log: Omit<DiaryLog, 'id'>, analyze: boolean) => void, onClose: () => void, isAIConfigured: boolean}> = ({onAddLog, onClose, isAIConfigured}) => {
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
const [title, setTitle] = useState('');
const [notes, setNotes] = useState('');
const [photos, setPhotos] = useState<string[]>([]);
const [tags, setTags] = useState<LogTag[]>([]);
const [requestAnalysis, setRequestAnalysis] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState('');
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files) {
const filePromises = Array.from(files).map(file => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve((reader.result as string).split(',')[1]);
reader.onerror = reject;
reader.readAsDataURL(file);
});
});
Promise.all(filePromises).then(base64Photos => setPhotos(p => [...p, ...base64Photos]));
}
};
const handleTagToggle = (tag: LogTag) => {
setTags(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title || !date) {
setError('Please add a title and date.');
return;
}
onAddLog({ date, title, notes, photos, tags }, requestAnalysis);
onClose();
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<input type="text" placeholder="Log Title (e.g., 'Spring Pruning')" value={title} onChange={e => setTitle(e.target.value)} required className="w-full p-2 border rounded-md" />
<input type="date" value={date} onChange={e => setDate(e.target.value)} required className="w-full p-2 border rounded-md" />
</div>
<textarea placeholder="Notes about today's activity or observations..." value={notes} onChange={e => setNotes(e.target.value)} className="w-full p-2 border rounded-md" rows={4}></textarea>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">Photos</label>
<div onClick={() => fileInputRef.current?.click()} className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-stone-300 border-dashed rounded-md cursor-pointer">
<div className="space-y-1 text-center">
<ImagePlusIcon className="mx-auto h-12 w-12 text-stone-400" />
<p className="text-sm text-stone-600">Click to upload images</p>
</div>
</div>
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" multiple className="sr-only"/>
{photos.length > 0 && <div className="mt-2 text-sm text-stone-600">{photos.length} photo(s) selected.</div>}
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">Tags</label>
<div className="flex flex-wrap gap-2">
{Object.values(LogTag).map(tag => (
<button type="button" key={tag} onClick={() => handleTagToggle(tag)} className={`px-3 py-1 text-sm rounded-full border transition-colors ${tags.includes(tag) ? 'bg-green-600 text-white border-green-600' : 'bg-white text-stone-700 border-stone-300 hover:bg-stone-50'}`}>
{tag}
</button>
))}
</div>
</div>
<div className={`flex items-center gap-3 p-3 rounded-md ${isAIConfigured ? 'bg-green-50' : 'bg-stone-100'}`}>
<SparklesIcon className={`w-8 h-8 ${isAIConfigured ? 'text-green-600' : 'text-stone-400'}`}/>
<div>
<label htmlFor="ai-analysis" className={`font-medium ${isAIConfigured ? 'text-green-800' : 'text-stone-500'}`}>Request AI Analysis</label>
<p className={`text-xs ${isAIConfigured ? 'text-green-700' : 'text-stone-500'}`}>Yuki will analyze your photos for changes and provide suggestions. (Requires at least one photo)</p>
</div>
<input id="ai-analysis" type="checkbox" checked={requestAnalysis} onChange={e => setRequestAnalysis(e.target.checked)} className="ml-auto h-5 w-5 rounded text-green-600 focus:ring-green-500" disabled={photos.length === 0 || !isAIConfigured} />
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="bg-stone-200 text-stone-700 px-4 py-2 rounded-md">Cancel</button>
<button type="submit" className="bg-green-700 text-white px-4 py-2 rounded-md">Add Log</button>
</div>
</form>
);
}
const GrowthVisualizer: React.FC<{tree: BonsaiTree, onClose: () => void}> = ({ tree, onClose }) => {
const logsWithPhotos = tree.logs.filter(l => l.photos.length > 0);
const [startLogId, setStartLogId] = useState<string>(logsWithPhotos.length > 1 ? logsWithPhotos[logsWithPhotos.length - 1].id : '');
const [endLogId, setEndLogId] = useState<string>(logsWithPhotos.length > 0 ? logsWithPhotos[0].id : '');
const [analysis, setAnalysis] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleGenerate = async () => {
setError('');
const startLog = logsWithPhotos.find(l => l.id === startLogId);
const endLog = logsWithPhotos.find(l => l.id === endLogId);
if (!startLog || !endLog || !startLog.photos[0] || !endLog.photos[0]) return;
setIsLoading(true);
setAnalysis(null);
const photoData = [
{ date: startLog.date, image: startLog.photos[0] },
{ date: endLog.date, image: endLog.photos[0] }
].sort((a,b) => new Date(a.date).getTime() - new Date(b.date).getTime());
try {
const result = await analyzeGrowthProgression(tree.species, photoData);
setAnalysis(result || "Could not generate analysis.");
} catch (e: any) {
setError(e.message);
} finally {
setIsLoading(false);
}
};
const startPhoto = logsWithPhotos.find(l => l.id === startLogId)?.photos[0];
const endPhoto = logsWithPhotos.find(l => l.id === endLogId)?.photos[0];
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium">Start Photo</label>
<select value={startLogId} onChange={e => setStartLogId(e.target.value)} className="w-full p-2 border rounded-md">
{logsWithPhotos.map(l => <option key={l.id} value={l.id}>{new Date(l.date).toLocaleDateString()} - {l.title}</option>)}
</select>
{startPhoto && <img src={`data:image/jpeg;base64,${startPhoto}`} className="mt-2 rounded-md w-full" />}
</div>
<div>
<label className="block text-sm font-medium">End Photo</label>
<select value={endLogId} onChange={e => setEndLogId(e.target.value)} className="w-full p-2 border rounded-md">
{logsWithPhotos.map(l => <option key={l.id} value={l.id}>{new Date(l.date).toLocaleDateString()} - {l.title}</option>)}
</select>
{endPhoto && <img src={`data:image/jpeg;base64,${endPhoto}`} className="mt-2 rounded-md w-full" />}
</div>
</div>
<button onClick={handleGenerate} disabled={isLoading || !startLogId || !endLogId || startLogId === endLogId} className="w-full flex items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 disabled:bg-stone-400">
<SparklesIcon className="w-5 h-5" />
{isLoading ? 'Yuki is Analyzing...' : 'Generate Growth Analysis'}
</button>
{error && <p className="text-sm text-red-600 text-center">{error}</p>}
{isLoading && <Spinner text="Analyzing progression..." />}
{analysis && (
<div className="my-3 p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg">
<h4 className="font-bold text-blue-800">Progression Analysis</h4>
<p className="text-sm text-blue-700 mt-2 whitespace-pre-wrap">{analysis}</p>
</div>
)}
</div>
);
};
const Modal: React.FC<{onClose: () => void, title: string, children: React.ReactNode}> = ({ onClose, title, children }) => {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-4xl p-6 relative" onClick={e => e.stopPropagation()}>
<h3 className="text-xl font-bold text-stone-900 mb-4">{title}</h3>
{children}
</div>
</div>
);
};
export default BonsaiDiaryView;