|
|
|
|
|
|
|
|
|
|
|
|
|
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'; |
|
|
|
|
|
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]); |
|
|
|
|
|
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> |
|
); |
|
}; |
|
|
|
|
|
|
|
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> |
|
{} |
|
<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; |
|
|
|
if (latestAnalysis && latestAnalysis.seasonalGuide && latestAnalysis.seasonalGuide.length > 0) { |
|
setGuideData(latestAnalysis.seasonalGuide); |
|
setIsLoading(false); |
|
return; |
|
} |
|
|
|
|
|
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; |
|
} |
|
|
|
|
|
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> |
|
); |
|
}; |
|
|
|
|
|
|
|
|
|
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; |