Severian commited on
Commit
be02369
·
verified ·
1 Parent(s): 431d0de

Upload 43 files

Browse files
App.tsx ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useContext, useEffect } from 'react';
2
+ import { AuthProvider, AuthContext } from './context/AuthContext';
3
+ import Sidebar from './components/Sidebar';
4
+ import AiStewardView from './views/AiStewardView';
5
+ import PestLibraryView from './views/PestLibraryView';
6
+ import SeasonalGuideView from './views/SeasonalGuideView';
7
+ import ToolGuideView from './views/ToolGuideView';
8
+ import BonsaiDiaryView from './views/BonsaiDiaryView';
9
+ import HealthCheckView from './views/HealthCheckView';
10
+ import SpeciesIdentifierView from './views/SpeciesIdentifierView';
11
+ import DesignStudioView from './views/DesignStudioView';
12
+ import WiringGuideView from './views/WiringGuideView';
13
+ import SoilAnalyzerView from './views/SoilAnalyzerView';
14
+ import NebariDeveloperView from './views/NebariDeveloperView';
15
+ import PotCalculatorView from './views/PotCalculatorView';
16
+ import SunTrackerView from './views/SunTrackerView';
17
+ import FertilizerMixerView from './views/FertilizerMixerView';
18
+ import SoilVolumeCalculatorView from './views/SoilVolumeCalculatorView';
19
+ import WeatherShieldView from './views/WeatherShieldView';
20
+ import SettingsView from './views/SettingsView';
21
+ import LoginView from './views/LoginView';
22
+ import { View } from './types';
23
+ import Spinner from './components/Spinner';
24
+
25
+ const AppContent: React.FC = () => {
26
+ const { user, isLoading } = useContext(AuthContext);
27
+ const [activeView, setActiveView] = useState<View>('garden');
28
+ const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
29
+
30
+ useEffect(() => {
31
+ if ('serviceWorker' in navigator) {
32
+ window.addEventListener('load', () => {
33
+ navigator.serviceWorker.register('/service-worker.js').then(registration => {
34
+ console.log('ServiceWorker registration successful with scope: ', registration.scope);
35
+ }).catch(error => {
36
+ console.log('ServiceWorker registration failed: ', error);
37
+ });
38
+ });
39
+ }
40
+ }, []);
41
+
42
+ const renderView = () => {
43
+ switch (activeView) {
44
+ case 'steward': return <AiStewardView setActiveView={setActiveView} />;
45
+ case 'pests': return <PestLibraryView setActiveView={setActiveView} />;
46
+ case 'seasons': return <SeasonalGuideView setActiveView={setActiveView} />;
47
+ case 'tools': return <ToolGuideView />;
48
+ case 'garden': return <BonsaiDiaryView setActiveView={setActiveView} />;
49
+ case 'healthCheck': return <HealthCheckView setActiveView={setActiveView} />;
50
+ case 'speciesIdentifier': return <SpeciesIdentifierView setActiveView={setActiveView} />;
51
+ case 'designStudio': return <DesignStudioView setActiveView={setActiveView} />;
52
+ case 'wiringGuide': return <WiringGuideView setActiveView={setActiveView} />;
53
+ case 'soilAnalyzer': return <SoilAnalyzerView setActiveView={setActiveView} />;
54
+ case 'nebariDeveloper': return <NebariDeveloperView setActiveView={setActiveView} />;
55
+ case 'potCalculator': return <PotCalculatorView />;
56
+ case 'sunTracker': return <SunTrackerView />;
57
+ case 'fertilizerMixer': return <FertilizerMixerView />;
58
+ case 'soilVolumeCalculator': return <SoilVolumeCalculatorView />;
59
+ case 'weatherShield': return <WeatherShieldView />;
60
+ case 'settings': return <SettingsView />;
61
+ default: return <BonsaiDiaryView setActiveView={setActiveView} />;
62
+ }
63
+ };
64
+
65
+ if (isLoading) {
66
+ return <div className="flex items-center justify-center h-screen bg-stone-100"><Spinner text="Loading Yuki..." /></div>;
67
+ }
68
+
69
+ if (!user) {
70
+ return <LoginView />;
71
+ }
72
+
73
+ return (
74
+ <div className="flex h-screen bg-stone-100">
75
+ <Sidebar
76
+ activeView={activeView}
77
+ setActiveView={setActiveView}
78
+ isCollapsed={isSidebarCollapsed}
79
+ setIsCollapsed={setIsSidebarCollapsed}
80
+ />
81
+ <main className="flex-1 p-8 overflow-y-auto">
82
+ {renderView()}
83
+ </main>
84
+ </div>
85
+ );
86
+ };
87
+
88
+ const AppWrapper = () => (
89
+ <AuthProvider>
90
+ <AppContent />
91
+ </AuthProvider>
92
+ );
93
+
94
+ export default AppWrapper;
components/AnalysisDisplay.tsx ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+ import React, { useState } from 'react';
4
+ import type { BonsaiAnalysis, ToolRecommendation } from '../types';
5
+ import {
6
+ CheckCircleIcon, AlertTriangleIcon, SparklesIcon, ZapIcon, BookOpenIcon,
7
+ ClipboardListIcon, ScissorsIcon, SunIcon, DropletIcon, ThermometerIcon,
8
+ CalendarIcon, BonsaiIcon, LayersIcon, FlaskConicalIcon, GalleryVerticalEndIcon,
9
+ WindIcon, SnowflakeIcon, SunriseIcon, LeafIcon, StethoscopeIcon, BugIcon, WrenchIcon,
10
+ BookUserIcon, PaletteIcon, DownloadIcon
11
+ } from './icons';
12
+ import Spinner from './Spinner';
13
+ import { generateBonsaiImage } from '../services/geminiService';
14
+
15
+ type Tab = 'Overview' | 'Care Plan' | 'Health and Pests' | 'Styling' | 'Fertilizer and Soil' | 'Seasonal Guide' | 'Diagnostics' | 'Pest Library' | 'Tools & Supplies' | 'Knowledge';
16
+
17
+ interface AnalysisDisplayProps {
18
+ analysis: BonsaiAnalysis;
19
+ onReset?: () => void;
20
+ onSaveToDiary?: () => void;
21
+ isReadonly?: boolean;
22
+ treeImageBase64?: string;
23
+ }
24
+
25
+ const TABS: { name: Tab, icon: React.FC<React.SVGProps<SVGSVGElement>> }[] = [
26
+ { name: 'Overview', icon: CheckCircleIcon },
27
+ { name: 'Care Plan', icon: CalendarIcon },
28
+ { name: 'Diagnostics', icon: StethoscopeIcon },
29
+ { name: 'Health and Pests', icon: AlertTriangleIcon },
30
+ { name: 'Pest Library', icon: BugIcon },
31
+ { name: 'Styling', icon: ScissorsIcon },
32
+ { name: 'Fertilizer and Soil', icon: LayersIcon },
33
+ { name: 'Seasonal Guide', icon: LeafIcon },
34
+ { name: 'Tools & Supplies', icon: WrenchIcon },
35
+ { name: 'Knowledge', icon: BookOpenIcon },
36
+ ];
37
+
38
+ const InfoCard: React.FC<{ title: string; children: React.ReactNode; icon: React.ReactNode; className?: string }> = ({ title, children, icon, className = '' }) => (
39
+ <div className={`bg-white rounded-xl shadow-md border border-stone-200 p-6 ${className}`}>
40
+ <div className="flex items-center gap-3 mb-4">
41
+ {icon}
42
+ <h3 className="text-xl font-semibold text-stone-800">{title}</h3>
43
+ </div>
44
+ <div className="space-y-3 text-stone-600">
45
+ {children}
46
+ </div>
47
+ </div>
48
+ );
49
+
50
+ const HealthGauge: React.FC<{ score: number }> = ({ score }) => {
51
+ const circumference = 2 * Math.PI * 52;
52
+ const offset = circumference - (score / 100) * circumference;
53
+ const color = score > 80 ? 'text-green-600' : score > 50 ? 'text-yellow-500' : 'text-red-600';
54
+
55
+ return (
56
+ <div className="relative w-32 h-32 flex items-center justify-center">
57
+ <svg className="absolute w-full h-full transform -rotate-90">
58
+ <circle className="text-stone-200" strokeWidth="10" stroke="currentColor" fill="transparent" r="52" cx="64" cy="64" />
59
+ <circle className={color} strokeWidth="10" strokeDasharray={circumference} strokeDashoffset={offset}
60
+ strokeLinecap="round" stroke="currentColor" fill="transparent" r="52" cx="64" cy="64" />
61
+ </svg>
62
+ <span className={`text-3xl font-bold ${color}`}>{score}</span>
63
+ </div>
64
+ );
65
+ };
66
+
67
+ const TabButton: React.FC<{ name: Tab, icon: React.ReactNode, isActive: boolean, onClick: () => void }> = ({ name, icon, isActive, onClick }) => (
68
+ <button
69
+ onClick={onClick}
70
+ className={`flex-shrink-0 flex items-center justify-center sm:justify-start gap-2 px-4 py-3 text-sm font-medium rounded-t-lg border-b-2 transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2
71
+ ${isActive
72
+ ? 'border-green-600 text-green-700 bg-white'
73
+ : 'border-transparent text-stone-500 hover:text-green-600 hover:bg-stone-100'
74
+ }`}
75
+ >
76
+ {icon}
77
+ <span className="hidden sm:inline">{name}</span>
78
+ </button>
79
+ );
80
+
81
+ const PotVisualizerModal: React.FC<{
82
+ isOpen: boolean;
83
+ onClose: () => void;
84
+ analysis: BonsaiAnalysis;
85
+ }> = ({ isOpen, onClose, analysis }) => {
86
+ const [isLoading, setIsLoading] = useState(false);
87
+ const [generatedImage, setGeneratedImage] = useState<string | null>(null);
88
+ const [error, setError] = useState('');
89
+ const [promptUsed, setPromptUsed] = useState('');
90
+
91
+ const potStyles = [
92
+ "A shallow, rectangular, unglazed, dark brown ceramic pot.",
93
+ "A round, blue-glazed ceramic pot with a soft patina.",
94
+ "An oval, cream-colored pot with delicate feet.",
95
+ "A modern, minimalist, square, grey concrete pot.",
96
+ "A classic, hexagonal, deep red pot.",
97
+ "A natural-looking pot carved from rock with rough texture."
98
+ ];
99
+
100
+ const handleGenerate = async (potStyle: string) => {
101
+ setIsLoading(true);
102
+ setError('');
103
+ setGeneratedImage(null);
104
+
105
+ const treeDescription = `A photorealistic image of a healthy ${analysis.species} bonsai tree. ${analysis.healthAssessment.observations.join(' ')}. The trunk is ${analysis.healthAssessment.trunkAndNebariHealth.toLowerCase()}.`;
106
+ const fullPrompt = `${treeDescription} The tree is in ${potStyle}`;
107
+ setPromptUsed(fullPrompt);
108
+
109
+ const result = await generateBonsaiImage(fullPrompt);
110
+ if (result) {
111
+ setGeneratedImage(result);
112
+ } else {
113
+ setError("Sorry, the AI couldn't generate the image. Please try a different style.");
114
+ }
115
+ setIsLoading(false);
116
+ };
117
+
118
+ if (!isOpen) return null;
119
+
120
+ return (
121
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
122
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl m-4 p-6 relative" onClick={e => e.stopPropagation()}>
123
+ <h3 className="text-2xl font-bold text-stone-900 mb-4">AI Pot Visualizer</h3>
124
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
125
+ <div>
126
+ <h4 className="font-semibold text-stone-800 mb-2">Choose a Pot Style:</h4>
127
+ <div className="space-y-2">
128
+ {potStyles.map(style => (
129
+ <button key={style} onClick={() => handleGenerate(style)} disabled={isLoading} className="w-full text-left p-3 bg-stone-100 hover:bg-green-100 hover:text-green-800 rounded-lg text-sm transition-colors disabled:opacity-50">
130
+ {style}
131
+ </button>
132
+ ))}
133
+ </div>
134
+ </div>
135
+ <div className="flex flex-col items-center justify-center bg-stone-100 rounded-lg p-4 min-h-[256px]">
136
+ {isLoading ? <Spinner text="Yuki is at the potter's wheel..." /> :
137
+ generatedImage ? (
138
+ <div className="space-y-2 text-center">
139
+ <img src={`data:image/jpeg;base64,${generatedImage}`} alt="Generated bonsai" className="rounded-lg shadow-md"/>
140
+ <a href={`data:image/jpeg;base64,${generatedImage}`} download="bonsai-pot-visualization.jpg" className="inline-flex items-center gap-2 text-xs text-green-700 hover:underline">
141
+ <DownloadIcon className="w-4 h-4" />
142
+ Download Image
143
+ </a>
144
+ </div>
145
+ ) :
146
+ error ? <p className="text-red-600 text-center">{error}</p> : <p className="text-stone-500 text-center">Your generated image will appear here.</p>
147
+ }
148
+ </div>
149
+ </div>
150
+ <button onClick={onClose} className="mt-6 w-full bg-stone-200 text-stone-700 font-semibold py-2 px-4 rounded-lg hover:bg-stone-300 transition-colors">
151
+ Close
152
+ </button>
153
+ </div>
154
+ </div>
155
+ );
156
+ };
157
+
158
+
159
+ const AnalysisDisplay: React.FC<AnalysisDisplayProps> = ({ analysis, onReset, onSaveToDiary, isReadonly = false, treeImageBase64 }) => {
160
+ const [activeTab, setActiveTab] = useState<Tab>('Overview');
161
+ const [isPotVisualizerOpen, setPotVisualizerOpen] = useState(false);
162
+
163
+ const {
164
+ healthAssessment, careSchedule, pestAndDiseaseAlerts, stylingSuggestions,
165
+ environmentalFactors, estimatedAge, species, wateringAnalysis, knowledgeNuggets,
166
+ fertilizerRecommendations, soilRecipe, potSuggestion, seasonalGuide,
167
+ diagnostics, pestLibrary, toolRecommendations
168
+ } = analysis;
169
+
170
+ const seasonIcons: { [key: string]: React.FC<React.SVGProps<SVGSVGElement>> } = {
171
+ Spring: SunriseIcon,
172
+ Summer: SunIcon,
173
+ Autumn: WindIcon,
174
+ Winter: SnowflakeIcon,
175
+ };
176
+
177
+ const renderContent = () => {
178
+ switch (activeTab) {
179
+ case 'Overview':
180
+ return (
181
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
182
+ <InfoCard title="At a Glance" icon={<BonsaiIcon className="w-7 h-7 text-green-700" />} className="lg:col-span-1 flex flex-col items-center text-center">
183
+ <HealthGauge score={healthAssessment.healthScore} />
184
+ <p className="text-lg font-medium mt-4"><strong className="text-stone-900">Overall Health:</strong> {healthAssessment.overallHealth}</p>
185
+ <p><strong className="text-stone-900">Species:</strong> {species}</p>
186
+ <p><strong className="text-stone-900">Estimated Age:</strong> {estimatedAge}</p>
187
+ </InfoCard>
188
+ <InfoCard title="Key Observations" icon={<CheckCircleIcon className="w-7 h-7 text-green-600" />} className="lg:col-span-2">
189
+ <ul className="list-disc list-inside space-y-2">
190
+ {healthAssessment.observations.map((obs, i) => <li key={i}>{obs}</li>)}
191
+ </ul>
192
+ </InfoCard>
193
+ <InfoCard title="Ideal Environment" icon={<SunIcon className="w-7 h-7 text-yellow-500" />} className="lg:col-span-3 grid grid-cols-1 md:grid-cols-3 gap-4">
194
+ <div className="flex items-center gap-3">
195
+ <SunIcon className="w-8 h-8 text-yellow-500"/>
196
+ <div><strong className="block text-stone-800">Light</strong>{environmentalFactors.idealLight}</div>
197
+ </div>
198
+ <div className="flex items-center gap-3">
199
+ <DropletIcon className="w-8 h-8 text-blue-500"/>
200
+ <div><strong className="block text-stone-800">Humidity</strong>{environmentalFactors.idealHumidity}</div>
201
+ </div>
202
+ <div className="flex items-center gap-3">
203
+ <ThermometerIcon className="w-8 h-8 text-red-500"/>
204
+ <div><strong className="block text-stone-800">Temperature</strong>{environmentalFactors.temperatureRange}</div>
205
+ </div>
206
+ </InfoCard>
207
+ </div>
208
+ );
209
+ case 'Care Plan':
210
+ return (
211
+ <div className="space-y-6">
212
+ <InfoCard title="Watering Analysis" icon={<DropletIcon className="w-7 h-7 text-blue-500" />}>
213
+ <p><strong className="text-stone-900">Frequency:</strong> {wateringAnalysis.frequency}</p>
214
+ <p><strong className="text-stone-900">Method:</strong> {wateringAnalysis.method}</p>
215
+ <p><strong className="text-stone-900">Notes:</strong> {wateringAnalysis.notes}</p>
216
+ </InfoCard>
217
+ <div>
218
+ <h3 className="text-2xl font-semibold text-stone-800 text-center mb-6">4-Week Personalized Care Schedule</h3>
219
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
220
+ {careSchedule.sort((a,b) => a.week - b.week).map((item, i) => (
221
+ <div key={i} className="bg-white p-5 rounded-lg border border-stone-200 shadow-sm flex flex-col">
222
+ <p className="font-bold text-green-800">Week {item.week}</p>
223
+ <p className="font-semibold text-stone-900 mt-1">{item.task}</p>
224
+ <p className="text-sm text-stone-600 mt-2 flex-grow">{item.details}</p>
225
+ {item.toolsNeeded && item.toolsNeeded.length > 0 && (
226
+ <div className="mt-3 pt-3 border-t border-stone-200">
227
+ <h4 className="text-xs font-bold text-stone-500 uppercase">Tools</h4>
228
+ <div className="flex flex-wrap gap-2 mt-1">
229
+ {item.toolsNeeded.map(tool => <span key={tool} className="text-xs bg-stone-100 text-stone-700 px-2 py-1 rounded-full">{tool}</span>)}
230
+ </div>
231
+ </div>
232
+ )}
233
+ </div>
234
+ ))}
235
+ </div>
236
+ </div>
237
+ </div>
238
+ );
239
+ case 'Diagnostics':
240
+ return (
241
+ <InfoCard title="Advanced Diagnostics" icon={<StethoscopeIcon className="w-7 h-7 text-blue-700" />}>
242
+ <div className="space-y-4">
243
+ {diagnostics.map((diag, i) => (
244
+ <div key={i} className="p-4 bg-stone-50 rounded-lg border border-stone-200">
245
+ <div className="flex justify-between items-baseline">
246
+ <h4 className="font-semibold text-stone-800">{diag.issue}</h4>
247
+ <span className={`px-2 py-0.5 text-xs font-medium rounded-full ${diag.confidence === 'High' ? 'bg-red-100 text-red-800' : diag.confidence === 'Medium' ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800'}`}>{diag.confidence} Confidence</span>
248
+ </div>
249
+ <p className="mt-2"><strong className="font-medium text-stone-700">Symptoms to watch for:</strong> {diag.symptoms}</p>
250
+ <p className="mt-1"><strong className="font-medium text-stone-700">Solution/Prevention:</strong> {diag.solution}</p>
251
+ </div>
252
+ ))}
253
+ </div>
254
+ </InfoCard>
255
+ );
256
+ case 'Health and Pests':
257
+ return (
258
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
259
+ <InfoCard title="Active Pest & Disease Alerts" icon={<AlertTriangleIcon className="w-7 h-7 text-amber-600" />}>
260
+ {pestAndDiseaseAlerts.length > 0 ? (
261
+ pestAndDiseaseAlerts.map((alert, i) => (
262
+ <div key={i} className="py-2 border-b border-stone-200 last:border-b-0">
263
+ <div className="flex justify-between items-baseline">
264
+ <p className="font-semibold text-stone-800">{alert.pestOrDisease}</p>
265
+ <span className={`px-2 py-0.5 text-xs font-medium rounded-full ${alert.severity === 'High' ? 'bg-red-100 text-red-800' : alert.severity === 'Medium' ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800'}`}>{alert.severity}</span>
266
+ </div>
267
+ <p><strong className="font-medium">Symptoms:</strong> {alert.symptoms}</p>
268
+ <p><strong className="font-medium">Treatment:</strong> {alert.treatment}</p>
269
+ </div>
270
+ ))
271
+ ) : <p>No active threats detected. Check the 'Pest Library' tab for preventative knowledge on common threats in your area.</p>}
272
+ </InfoCard>
273
+ <InfoCard title="Detailed Health Breakdown" icon={<CheckCircleIcon className="w-7 h-7 text-green-600" />}>
274
+ <p><strong className="font-medium text-stone-900">Foliage:</strong> {healthAssessment.foliageHealth}</p>
275
+ <p><strong className="font-medium text-stone-900">Trunk & Nebari:</strong> {healthAssessment.trunkAndNebariHealth}</p>
276
+ <p><strong className="font-medium text-stone-900">Pot & Soil:</strong> {healthAssessment.potAndSoilHealth}</p>
277
+ </InfoCard>
278
+ </div>
279
+ );
280
+ case 'Pest Library':
281
+ return (
282
+ <InfoCard title="Regional Pest & Disease Library" icon={<BugIcon className="w-7 h-7 text-red-700" />}>
283
+ <p className="text-sm mb-4">A reference for the most common threats to a {species} in your region.</p>
284
+ <div className="space-y-4">
285
+ {pestLibrary.map((pest, i) => (
286
+ <details key={i} className="p-4 bg-stone-50 rounded-lg border border-stone-200 group">
287
+ <summary className="font-semibold text-stone-800 cursor-pointer flex justify-between items-center">
288
+ {pest.name} ({pest.type})
289
+ <span className="text-xs text-stone-500 group-open:hidden">Show Details</span>
290
+ <span className="text-xs text-stone-500 hidden group-open:inline">Hide Details</span>
291
+ </summary>
292
+ <div className="mt-4 space-y-3 text-sm">
293
+ <p>{pest.description}</p>
294
+ <div>
295
+ <strong className="font-medium text-stone-700">Symptoms:</strong>
296
+ <ul className="list-disc list-inside ml-2">
297
+ {pest.symptoms.map((s, idx) => <li key={idx}>{s}</li>)}
298
+ </ul>
299
+ </div>
300
+ <div>
301
+ <strong className="font-medium text-stone-700">Organic Treatment:</strong>
302
+ <p>{pest.treatment.organic}</p>
303
+ </div>
304
+ <div>
305
+ <strong className="font-medium text-stone-700">Chemical Treatment:</strong>
306
+ <p>{pest.treatment.chemical}</p>
307
+ </div>
308
+ </div>
309
+ </details>
310
+ ))}
311
+ </div>
312
+ </InfoCard>
313
+ );
314
+ case 'Styling':
315
+ return (
316
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
317
+ <InfoCard title="Styling & Shaping Advice" icon={<ScissorsIcon className="w-7 h-7 text-indigo-600" />} className="lg:col-span-1">
318
+ {stylingSuggestions.length > 0 ? (
319
+ stylingSuggestions.map((suggestion, i) => (
320
+ <div key={i} className="py-3 border-b border-stone-200 last:border-b-0">
321
+ <p className="font-semibold text-stone-800">{suggestion.technique} ({suggestion.area})</p>
322
+ <p className="mt-1">{suggestion.description}</p>
323
+ </div>
324
+ ))
325
+ ) : <p>No immediate styling is recommended. Focus on health first.</p>}
326
+ </InfoCard>
327
+ <InfoCard title="Pot Recommendation" icon={<GalleryVerticalEndIcon className="w-7 h-7 text-orange-700" />} className="lg:col-span-1">
328
+ <p><strong className="font-medium text-stone-900">Style:</strong> {potSuggestion.style}</p>
329
+ <p><strong className="font-medium text-stone-900">Size:</strong> {potSuggestion.size}</p>
330
+ <p><strong className="font-medium text-stone-900">Color Palette:</strong> {potSuggestion.colorPalette}</p>
331
+ <p className="mt-3 pt-3 border-t border-stone-200"><strong className="font-medium text-stone-900">Rationale:</strong> {potSuggestion.rationale}</p>
332
+ <button onClick={() => setPotVisualizerOpen(true)} className="mt-4 w-full flex items-center justify-center gap-2 rounded-md bg-orange-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-orange-500">
333
+ <PaletteIcon className="w-5 h-5"/>
334
+ Visualize Pot Pairings
335
+ </button>
336
+ </InfoCard>
337
+ </div>
338
+ );
339
+ case 'Fertilizer and Soil':
340
+ return (
341
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
342
+ <InfoCard title="Fertilizer Schedule" icon={<FlaskConicalIcon className="w-7 h-7 text-cyan-600" />}>
343
+ {fertilizerRecommendations.map(rec => (
344
+ <div key={rec.phase} className="py-2 border-b border-stone-200 last:border-b-0">
345
+ <p className="font-semibold text-stone-800">{rec.phase}</p>
346
+ <p><strong className="font-medium">Type:</strong> {rec.type}</p>
347
+ <p><strong className="font-medium">Frequency:</strong> {rec.frequency}</p>
348
+ <p className="text-sm italic">Notes: {rec.notes}</p>
349
+ </div>
350
+ ))}
351
+ </InfoCard>
352
+ <InfoCard title="Recommended Soil Mix" icon={<LayersIcon className="w-7 h-7 text-amber-700" />}>
353
+ <div className="space-y-3">
354
+ {soilRecipe.components.map(comp => (
355
+ <div key={comp.name}>
356
+ <div className="flex justify-between items-center mb-1">
357
+ <span className="font-medium text-stone-800">{comp.name}</span>
358
+ <span className="font-semibold text-amber-800">{comp.percentage}%</span>
359
+ </div>
360
+ <div className="w-full bg-stone-200 rounded-full h-2.5">
361
+ <div className="bg-amber-600 h-2.5 rounded-full" style={{ width: `${comp.percentage}%` }}></div>
362
+ </div>
363
+ <p className="text-xs italic text-stone-500 mt-1">{comp.notes}</p>
364
+ </div>
365
+ ))}
366
+ </div>
367
+ <p className="mt-4 pt-4 border-t border-stone-200"><strong className="font-medium text-stone-900">Rationale:</strong> {soilRecipe.rationale}</p>
368
+ </InfoCard>
369
+ </div>
370
+ );
371
+ case 'Seasonal Guide':
372
+ return (
373
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
374
+ {seasonalGuide.sort((a,b) => ['Spring', 'Summer', 'Autumn', 'Winter'].indexOf(a.season) - ['Spring', 'Summer', 'Autumn', 'Winter'].indexOf(b.season)).map(season => {
375
+ const Icon = seasonIcons[season.season] || LeafIcon;
376
+ return (
377
+ <InfoCard key={season.season} title={season.season} icon={<Icon className="w-7 h-7 text-green-700" />}>
378
+ <p className="italic text-stone-600 mb-4">{season.summary}</p>
379
+ <ul className="space-y-2">
380
+ {season.tasks.map(task => (
381
+ <li key={task.task} className="flex items-center justify-between text-sm">
382
+ <span>{task.task}</span>
383
+ <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>
384
+ </li>
385
+ ))}
386
+ </ul>
387
+ </InfoCard>
388
+ )
389
+ })}
390
+ </div>
391
+ );
392
+ case 'Tools & Supplies':
393
+ const groupedTools = toolRecommendations.reduce((acc, tool) => {
394
+ acc[tool.category] = acc[tool.category] || [];
395
+ acc[tool.category].push(tool);
396
+ return acc;
397
+ }, {} as Record<ToolRecommendation['category'], ToolRecommendation[]>);
398
+
399
+ return (
400
+ <div className="space-y-6">
401
+ {Object.entries(groupedTools).map(([category, tools]) => (
402
+ <InfoCard key={category} title={category} icon={<WrenchIcon className="w-7 h-7 text-gray-700" />}>
403
+ <div className="space-y-3">
404
+ {tools.map(tool => (
405
+ <div key={tool.name} className="py-2 border-b border-stone-100 last:border-0">
406
+ <div className="flex justify-between items-baseline">
407
+ <p className="font-semibold text-stone-800">{tool.name}</p>
408
+ <span className={`px-2 py-0.5 text-xs font-medium rounded-full ${tool.level === 'Essential' ? 'bg-green-100 text-green-800' : tool.level === 'Recommended' ? 'bg-blue-100 text-blue-800' : 'bg-orange-100 text-orange-800'}`}>{tool.level}</span>
409
+ </div>
410
+ <p className="text-sm">{tool.description}</p>
411
+ </div>
412
+ ))}
413
+ </div>
414
+ </InfoCard>
415
+ ))}
416
+ </div>
417
+ );
418
+ case 'Knowledge':
419
+ return (
420
+ <InfoCard title={`Master's Wisdom: ${species}`} icon={<BookOpenIcon className="w-7 h-7 text-purple-600" />}>
421
+ <ul className="space-y-4">
422
+ {knowledgeNuggets.map((nugget, i) => (
423
+ <li key={i} className="flex items-start gap-3">
424
+ <SparklesIcon className="w-5 h-5 text-yellow-500 mt-1 flex-shrink-0" />
425
+ <span>{nugget}</span>
426
+ </li>
427
+ ))}
428
+ </ul>
429
+ </InfoCard>
430
+ );
431
+ default:
432
+ return null;
433
+ }
434
+ };
435
+
436
+ return (
437
+ <div className="w-full max-w-6xl mx-auto space-y-8">
438
+ <PotVisualizerModal isOpen={isPotVisualizerOpen} onClose={() => setPotVisualizerOpen(false)} analysis={analysis} />
439
+ <div className="text-center">
440
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl">Your Bonsai Analysis is Ready</h2>
441
+ <p className="mt-4 text-lg leading-8 text-stone-600">
442
+ Master Yuki has assessed your <span className="font-semibold text-green-700">{species}</span>. Here is your report.
443
+ </p>
444
+ </div>
445
+
446
+ <div className="bg-stone-50 rounded-xl p-1 sm:p-2 sticky top-2 z-20 shadow-sm border border-stone-200">
447
+ <div className="flex flex-nowrap items-center justify-start -mb-px border-b border-stone-200 overflow-x-auto">
448
+ {TABS.map(({name, icon: Icon}) => (
449
+ <TabButton key={name} name={name} icon={<Icon className="w-5 h-5" />} isActive={activeTab === name} onClick={() => setActiveTab(name)} />
450
+ ))}
451
+ </div>
452
+ </div>
453
+
454
+ <div className="bg-stone-100 p-4 sm:p-6 lg:p-8 rounded-2xl">
455
+ {renderContent()}
456
+ </div>
457
+
458
+ {!isReadonly && (
459
+ <div className="text-center pt-6 flex flex-col sm:flex-row justify-center items-center gap-4">
460
+ <button
461
+ onClick={onReset}
462
+ className="w-full sm:w-auto bg-stone-200 text-stone-700 font-semibold py-3 px-6 rounded-lg hover:bg-stone-300 transition-colors"
463
+ >
464
+ Analyze Another Tree
465
+ </button>
466
+ {onSaveToDiary && (
467
+ <button
468
+ onClick={onSaveToDiary}
469
+ className="flex items-center gap-2 w-full sm:w-auto justify-center rounded-md bg-green-700 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-green-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-700"
470
+ >
471
+ <BookUserIcon className="w-5 h-5"/>
472
+ Save Tree to My Garden
473
+ </button>
474
+ )}
475
+ </div>
476
+ )}
477
+ </div>
478
+ );
479
+ };
480
+
481
+ export default AnalysisDisplay;
components/ImageUploader.tsx ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+
4
+ import React, { useState, useRef, useEffect } from 'react';
5
+ import { UploadCloudIcon, SparklesIcon } from './icons';
6
+
7
+ interface ImageUploaderProps {
8
+ onAnalyze: (imageBase64: string, species: string, location: string) => void;
9
+ isAnalyzing: boolean;
10
+ defaultSpecies?: string;
11
+ defaultLocation?: string;
12
+ disabled?: boolean;
13
+ }
14
+
15
+ const ImageUploader: React.FC<ImageUploaderProps> = ({ onAnalyze, isAnalyzing, defaultSpecies = '', defaultLocation = '', disabled = false }) => {
16
+ const [imagePreview, setImagePreview] = useState<string | null>(null);
17
+ const [imageBase64, setImageBase64] = useState<string>('');
18
+ const [species, setSpecies] = useState<string>(defaultSpecies);
19
+ const [location, setLocation] = useState<string>(defaultLocation);
20
+ const [error, setError] = useState<string>('');
21
+ const fileInputRef = useRef<HTMLInputElement>(null);
22
+
23
+ useEffect(() => {
24
+ setSpecies(defaultSpecies);
25
+ setLocation(defaultLocation);
26
+ }, [defaultSpecies, defaultLocation]);
27
+
28
+
29
+ const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
30
+ const file = event.target.files?.[0];
31
+ if (file) {
32
+ if (file.size > 4 * 1024 * 1024) { // 4MB limit
33
+ setError("File size exceeds 4MB. Please upload a smaller image.");
34
+ return;
35
+ }
36
+ const reader = new FileReader();
37
+ reader.onloadend = () => {
38
+ const base64String = (reader.result as string).split(',')[1];
39
+ setImagePreview(reader.result as string);
40
+ setImageBase64(base64String);
41
+ setError('');
42
+ };
43
+ reader.onerror = () => {
44
+ setError("Failed to read the file.");
45
+ }
46
+ reader.readAsDataURL(file);
47
+ }
48
+ };
49
+
50
+ const handleAnalyzeClick = () => {
51
+ if (!imageBase64) {
52
+ setError('Please upload an image of your bonsai.');
53
+ return;
54
+ }
55
+ if (!species.trim()) {
56
+ setError('Please enter the bonsai species.');
57
+ return;
58
+ }
59
+ if (!location.trim()) {
60
+ setError('Please enter your city or region for climate data.');
61
+ return;
62
+ }
63
+ setError('');
64
+ onAnalyze(imageBase64, species, location);
65
+ };
66
+
67
+ const triggerFileSelect = () => fileInputRef.current?.click();
68
+
69
+ return (
70
+ <div className="w-full max-w-2xl mx-auto bg-white p-8 rounded-2xl shadow-lg border border-stone-200">
71
+ <div className="space-y-6">
72
+ <div>
73
+ <h2 className="text-lg font-medium text-stone-900">Provide Tree Details</h2>
74
+ <p className="mt-1 text-sm text-stone-600">
75
+ Upload a clear photo and tell us about your tree for the most accurate AI analysis.
76
+ </p>
77
+ </div>
78
+
79
+ <div
80
+ className="mt-2 flex justify-center rounded-lg border-2 border-dashed border-stone-300 px-6 py-10 hover:border-green-600 transition-colors cursor-pointer"
81
+ onClick={triggerFileSelect}
82
+ onDragOver={(e) => e.preventDefault()}
83
+ onDrop={(e) => {
84
+ e.preventDefault();
85
+ if (e.dataTransfer.files) {
86
+ const mockEvent = { target: { files: e.dataTransfer.files } } as unknown as React.ChangeEvent<HTMLInputElement>;
87
+ handleFileChange(mockEvent);
88
+ }
89
+ }}
90
+ >
91
+ <div className="text-center">
92
+ {imagePreview ? (
93
+ <img src={imagePreview} alt="Bonsai preview" className="mx-auto h-40 w-auto rounded-md object-cover" />
94
+ ) : (
95
+ <>
96
+ <UploadCloudIcon className="mx-auto h-12 w-12 text-stone-400" aria-hidden="true" />
97
+ <div className="mt-4 flex text-sm leading-6 text-stone-600">
98
+ <span className="relative font-semibold text-green-700 focus-within:outline-none focus-within:ring-2 focus-within:ring-green-600 focus-within:ring-offset-2 hover:text-green-500">
99
+ Upload a file
100
+ </span>
101
+ <input ref={fileInputRef} id="file-upload" name="file-upload" type="file" className="sr-only" onChange={handleFileChange} accept="image/png, image/jpeg" />
102
+ <p className="pl-1">or drag and drop</p>
103
+ </div>
104
+ <p className="text-xs leading-5 text-stone-500">PNG, JPG up to 4MB</p>
105
+ </>
106
+ )}
107
+ </div>
108
+ </div>
109
+
110
+ <div className="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-2">
111
+ <div>
112
+ <label htmlFor="species" className="block text-sm font-medium leading-6 text-stone-900">
113
+ Bonsai Species
114
+ </label>
115
+ <div className="mt-2">
116
+ <input
117
+ type="text"
118
+ name="species"
119
+ id="species"
120
+ value={species}
121
+ onChange={(e) => setSpecies(e.target.value)}
122
+ 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-green-600 sm:text-sm sm:leading-6"
123
+ placeholder="e.g., Japanese Maple, Ficus"
124
+ />
125
+ </div>
126
+ </div>
127
+ <div>
128
+ <label htmlFor="location" className="block text-sm font-medium leading-6 text-stone-900">
129
+ Your Location (City/Region)
130
+ </label>
131
+ <div className="mt-2">
132
+ <input
133
+ type="text"
134
+ name="location"
135
+ id="location"
136
+ value={location}
137
+ onChange={(e) => setLocation(e.target.value)}
138
+ 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-green-600 sm:text-sm sm:leading-6"
139
+ placeholder="e.g., San Francisco, CA"
140
+ />
141
+ </div>
142
+ </div>
143
+ </div>
144
+ {error && <p className="text-sm text-red-600">{error}</p>}
145
+ <div className="mt-6">
146
+ <button
147
+ type="button"
148
+ onClick={handleAnalyzeClick}
149
+ disabled={isAnalyzing || disabled}
150
+ 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 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-700 disabled:bg-stone-400 disabled:cursor-not-allowed"
151
+ >
152
+ <SparklesIcon className="-ml-0.5 h-5 w-5" aria-hidden="true" />
153
+ {isAnalyzing ? 'Analyzing...' : 'Get AI Analysis'}
154
+ </button>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ );
159
+ };
160
+
161
+ export default ImageUploader;
components/Sidebar.tsx ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import type { View } from '../types';
3
+ import {
4
+ BonsaiIcon,
5
+ SparklesIcon,
6
+ BugIcon,
7
+ LeafIcon,
8
+ WrenchIcon,
9
+ BookUserIcon,
10
+ StethoscopeIcon,
11
+ PaletteIcon,
12
+ ScanIcon,
13
+ SnailIcon,
14
+ FilterIcon,
15
+ RootsIcon,
16
+ PotRulerIcon,
17
+ SunClockIcon,
18
+ BeakerIcon,
19
+ ShovelIcon,
20
+ UmbrellaIcon,
21
+ ScissorsIcon,
22
+ SettingsIcon,
23
+ ChevronsLeftIcon
24
+ } from './icons';
25
+ import { AuthContext } from '../context/AuthContext';
26
+
27
+
28
+ interface SidebarProps {
29
+ activeView: View;
30
+ setActiveView: (view: View) => void;
31
+ isCollapsed: boolean;
32
+ setIsCollapsed: (isCollapsed: boolean) => void;
33
+ }
34
+
35
+ const CATEGORIZED_NAV_ITEMS = [
36
+ {
37
+ category: 'Core',
38
+ items: [
39
+ { id: 'garden', name: 'My Garden', icon: BookUserIcon },
40
+ { id: 'steward', name: 'New Tree Analysis', icon: SparklesIcon },
41
+ ]
42
+ },
43
+ {
44
+ category: 'AI Studios',
45
+ items: [
46
+ { id: 'designStudio', name: 'AI Design Studio', icon: PaletteIcon },
47
+ { id: 'wiringGuide', name: 'AI Wiring Guide', icon: SnailIcon },
48
+ { id: 'nebariDeveloper', name: 'Nebari Developer', icon: RootsIcon },
49
+ // { id: 'virtualTrimmer', name: 'Virtual Trimmer', icon: ScissorsIcon },
50
+ ]
51
+ },
52
+ {
53
+ category: 'Diagnostics',
54
+ items: [
55
+ { id: 'healthCheck', name: 'Health Check-up', icon: StethoscopeIcon },
56
+ { id: 'speciesIdentifier', name: 'Species Identifier', icon: ScanIcon },
57
+ { id: 'soilAnalyzer', name: 'Soil Analyzer', icon: FilterIcon },
58
+ { id: 'weatherShield', name: 'Weather Shield', icon: UmbrellaIcon },
59
+ ]
60
+ },
61
+ {
62
+ category: 'Utilities',
63
+ items: [
64
+ { id: 'sunTracker', name: 'Sun Tracker', icon: SunClockIcon },
65
+ { id: 'potCalculator', name: 'Pot Calculator', icon: PotRulerIcon },
66
+ { id: 'fertilizerMixer', name: 'Fertilizer Mixer', icon: BeakerIcon },
67
+ { id: 'soilVolumeCalculator', name: 'Soil Mix Calculator', icon: ShovelIcon },
68
+ { id: 'tools', name: 'Tool Guide', icon: WrenchIcon },
69
+ ]
70
+ },
71
+ {
72
+ category: 'Reference',
73
+ items: [
74
+ { id: 'pests', name: 'Pest Library', icon: BugIcon },
75
+ { id: 'seasons', name: 'Seasonal Guides', icon: LeafIcon },
76
+ ]
77
+ },
78
+ ];
79
+
80
+
81
+ const Sidebar: React.FC<SidebarProps> = ({ activeView, setActiveView, isCollapsed, setIsCollapsed }) => {
82
+ const { logout } = React.useContext(AuthContext);
83
+
84
+ return (
85
+ <aside className={`flex-shrink-0 bg-white border-r border-stone-200 flex flex-col transition-all duration-300 ${isCollapsed ? 'w-20' : 'w-64'}`}>
86
+ <div className={`h-16 flex items-center border-b border-stone-200 transition-all duration-300 ${isCollapsed ? 'justify-center' : 'justify-center px-4'}`}>
87
+ <div className="flex items-center gap-2">
88
+ <BonsaiIcon className="h-8 w-8 text-green-700 flex-shrink-0" />
89
+ {!isCollapsed && <h1 className="text-xl font-bold text-stone-800">Yuki</h1>}
90
+ </div>
91
+ </div>
92
+ <nav className="flex-1 px-2 py-4 space-y-2 overflow-y-auto">
93
+ {CATEGORIZED_NAV_ITEMS.map((category) => (
94
+ <div key={category.category}>
95
+ {!isCollapsed && <h2 className="px-4 text-xs font-bold uppercase text-stone-500 tracking-wider">{category.category}</h2>}
96
+ {isCollapsed && category.category === 'Core' && <div className="h-px bg-stone-200 my-2 mx-4"></div>}
97
+ <div className="mt-2 space-y-1">
98
+ {category.items.map((item) => (
99
+ <button
100
+ key={item.id}
101
+ onClick={() => setActiveView(item.id as View)}
102
+ title={item.name}
103
+ className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${isCollapsed ? 'justify-center' : ''}
104
+ ${
105
+ activeView === item.id
106
+ ? 'bg-green-100 text-green-800'
107
+ : 'text-stone-600 hover:bg-stone-100 hover:text-stone-900'
108
+ }
109
+ `}
110
+ >
111
+ <item.icon className="h-5 w-5 flex-shrink-0" />
112
+ {!isCollapsed && <span>{item.name}</span>}
113
+ </button>
114
+ ))}
115
+ </div>
116
+ </div>
117
+ ))}
118
+ </nav>
119
+ <div className="flex-shrink-0 p-2 border-t border-stone-200">
120
+ <button
121
+ key="settings"
122
+ onClick={() => setActiveView('settings')}
123
+ title="Settings"
124
+ className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${isCollapsed ? 'justify-center' : ''}
125
+ ${
126
+ activeView === 'settings'
127
+ ? 'bg-green-100 text-green-800'
128
+ : 'text-stone-600 hover:bg-stone-100 hover:text-stone-900'
129
+ }
130
+ `}
131
+ >
132
+ <SettingsIcon className="h-5 w-5 flex-shrink-0" />
133
+ {!isCollapsed && <span>Settings</span>}
134
+ </button>
135
+ <div className="p-2 border-t border-stone-200 mt-2">
136
+ <button
137
+ onClick={() => setIsCollapsed(!isCollapsed)}
138
+ className={`w-full flex items-center gap-3 px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-100 rounded-lg ${isCollapsed ? 'justify-center' : ''}`}
139
+ title={isCollapsed ? 'Expand Sidebar' : 'Collapse Sidebar'}
140
+ >
141
+ <ChevronsLeftIcon className={`h-5 w-5 flex-shrink-0 transition-transform duration-300 ${isCollapsed ? 'rotate-180' : ''}`} />
142
+ {!isCollapsed && <span>Collapse</span>}
143
+ </button>
144
+ </div>
145
+ </div>
146
+ </aside>
147
+ );
148
+ };
149
+
150
+ export default Sidebar;
components/Spinner.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { BonsaiIcon } from './icons';
4
+
5
+ interface SpinnerProps {
6
+ text?: string;
7
+ }
8
+
9
+ const Spinner: React.FC<SpinnerProps> = ({ text = "Yuki the Bonsai Sensei is analyzing your tree..." }) => {
10
+ return (
11
+ <div className="flex flex-col items-center justify-center gap-4 text-center p-8">
12
+ <BonsaiIcon className="h-12 w-12 text-green-700 animate-pulse" />
13
+ <p className="text-lg font-medium text-stone-600">{text}</p>
14
+ <div className="w-48 h-2 bg-stone-200 rounded-full overflow-hidden">
15
+ <div className="h-full bg-green-600 rounded-full animate-[progress_2s_ease-in-out_infinite]"></div>
16
+ </div>
17
+ <style>{`
18
+ @keyframes progress {
19
+ 0% { width: 0%; }
20
+ 50% { width: 100%; }
21
+ 100% { width: 0%; }
22
+ }
23
+ `}</style>
24
+ </div>
25
+ );
26
+ };
27
+
28
+ export default Spinner;
components/UserProfile.tsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ const UserProfile: React.FC = () => {
4
+ // This component is no longer used in the anonymous auth model.
5
+ // Returning null to prevent it from rendering.
6
+ return null;
7
+ };
8
+
9
+ export default UserProfile;
components/icons.tsx ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+ import type React from 'react';
4
+
5
+ export const BonsaiIcon = (props: React.SVGProps<SVGSVGElement>) => (
6
+ <svg
7
+ {...props}
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ width="24"
10
+ height="24"
11
+ viewBox="0 0 24 24"
12
+ fill="none"
13
+ stroke="currentColor"
14
+ strokeWidth="2"
15
+ strokeLinecap="round"
16
+ strokeLinejoin="round"
17
+ >
18
+ <path d="M14 15v-3a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v3" />
19
+ <path d="M14 15H8" />
20
+ <path d="M17.5 15.5c2.3-1.4 3.5-4 3.5-6.5a8 8 0 0 0-16 0c0 2.5 1.2 5.1 3.5 6.5" />
21
+ <path d="M6 18h12" />
22
+ </svg>
23
+ );
24
+
25
+ export const UploadCloudIcon = (props: React.SVGProps<SVGSVGElement>) => (
26
+ <svg
27
+ {...props}
28
+ xmlns="http://www.w3.org/2000/svg"
29
+ width="24"
30
+ height="24"
31
+ viewBox="0 0 24 24"
32
+ fill="none"
33
+ stroke="currentColor"
34
+ strokeWidth="2"
35
+ strokeLinecap="round"
36
+ strokeLinejoin="round"
37
+ >
38
+ <path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
39
+ <path d="M12 12v9" />
40
+ <path d="m16 16-4-4-4 4" />
41
+ </svg>
42
+ );
43
+
44
+ export const SparklesIcon = (props: React.SVGProps<SVGSVGElement>) => (
45
+ <svg
46
+ {...props}
47
+ xmlns="http://www.w3.org/2000/svg"
48
+ width="24"
49
+ height="24"
50
+ viewBox="0 0 24 24"
51
+ fill="none"
52
+ stroke="currentColor"
53
+ strokeWidth="2"
54
+ strokeLinecap="round"
55
+ strokeLinejoin="round"
56
+ >
57
+ <path d="M9.93 2.65a2.5 2.5 0 0 1 4.14 0" />
58
+ <path d="M8.2 6.4a2.5 2.5 0 0 1 4.14 0" />
59
+ <path d="m18.07 10.5-1.14 1.14" />
60
+ <path d="M6.07 10.5 5 11.64" />
61
+ <path d="M12 2v2.5" />
62
+ <path d="M12 20v2" />
63
+ <path d="M21.17 8.83 19 11" />
64
+ <path d="M5.17 8.83 3 11" />
65
+ <path d="M12 12a7.5 7.5 0 0 0-7.5 7.5c0 2.5 4 2.5 7.5 0s7.5-2.5 7.5 0c0-4.14-3.36-7.5-7.5-7.5Z" />
66
+ <path d="m13.5 12 .5-1.5" />
67
+ </svg>
68
+ );
69
+
70
+ export const CheckCircleIcon = (props: React.SVGProps<SVGSVGElement>) => (
71
+ <svg
72
+ {...props}
73
+ xmlns="http://www.w3.org/2000/svg"
74
+ width="24"
75
+ height="24"
76
+ viewBox="0 0 24 24"
77
+ fill="none"
78
+ stroke="currentColor"
79
+ strokeWidth="2"
80
+ strokeLinecap="round"
81
+ strokeLinejoin="round"
82
+ >
83
+ <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" />
84
+ <path d="m9 12 2 2 4-4" />
85
+ </svg>
86
+ );
87
+
88
+ export const AlertTriangleIcon = (props: React.SVGProps<SVGSVGElement>) => (
89
+ <svg
90
+ {...props}
91
+ xmlns="http://www.w3.org/2000/svg"
92
+ width="24"
93
+ height="24"
94
+ viewBox="0 0 24 24"
95
+ fill="none"
96
+ stroke="currentColor"
97
+ strokeWidth="2"
98
+ strokeLinecap="round"
99
+ strokeLinejoin="round"
100
+ >
101
+ <path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
102
+ <path d="M12 9v4" />
103
+ <path d="M12 17h.01" />
104
+ </svg>
105
+ );
106
+
107
+ export const ZapIcon = (props: React.SVGProps<SVGSVGElement>) => (
108
+ <svg
109
+ {...props}
110
+ xmlns="http://www.w3.org/2000/svg"
111
+ width="24"
112
+ height="24"
113
+ viewBox="0 0 24 24"
114
+ fill="none"
115
+ stroke="currentColor"
116
+ strokeWidth="2"
117
+ strokeLinecap="round"
118
+ strokeLinejoin="round"
119
+ >
120
+ <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
121
+ </svg>
122
+ );
123
+
124
+ export const BookOpenIcon = (props: React.SVGProps<SVGSVGElement>) => (
125
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
126
+ );
127
+
128
+ export const ClipboardListIcon = (props: React.SVGProps<SVGSVGElement>) => (
129
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg>
130
+ );
131
+
132
+ export const ScissorsIcon = (props: React.SVGProps<SVGSVGElement>) => (
133
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" x2="8.12" y1="4" y2="15.88"/><line x1="14.47" x2="20" y1="14.48" y2="20"/><line x1="8.12" x2="12" y1="8.12" y2="12"/></svg>
134
+ );
135
+
136
+ export const SunIcon = (props: React.SVGProps<SVGSVGElement>) => (
137
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m4.93 19.07 1.41-1.41"/><path d="m17.66 6.34 1.41-1.41"/></svg>
138
+ );
139
+
140
+ export const DropletIcon = (props: React.SVGProps<SVGSVGElement>) => (
141
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C5 11.1 4 13 4 15a7 7 0 0 0 7 7z"/></svg>
142
+ );
143
+
144
+ export const ThermometerIcon = (props: React.SVGProps<SVGSVGElement>) => (
145
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z"/></svg>
146
+ );
147
+
148
+ export const CalendarIcon = (props: React.SVGProps<SVGSVGElement>) => (
149
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/></svg>
150
+ );
151
+
152
+ export const LayersIcon = (props: React.SVGProps<SVGSVGElement>) => (
153
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
154
+ );
155
+
156
+ export const FlaskConicalIcon = (props: React.SVGProps<SVGSVGElement>) => (
157
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 2v7.31"/><path d="M14 9.31V2"/><path d="M3 14a6.99 6.99 0 0 1 .15-1.39l.8-4.02A2 2 0 0 1 6 7h12a2 2 0 0 1 2.05 1.61l.8 4.02A6.99 6.99 0 0 1 21 14v1a2 2 0 0 1-2 2h-6.23a2 2 0 0 1-1.54-.78L10 15H4a2 2 0 0 1-2-2v-1Z"/></svg>
158
+ );
159
+
160
+ export const GalleryVerticalEndIcon = (props: React.SVGProps<SVGSVGElement>) => (
161
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 2h10"/><rect width="18" height="12" x="3" y="6" rx="2"/><path d="M7 22h10"/></svg>
162
+ );
163
+
164
+ export const WindIcon = (props: React.SVGProps<SVGSVGElement>) => (
165
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17.7 7.7a2.5 2.5 0 1 1 1.8 4.3H2"/><path d="M9.6 4.6A2 2 0 1 1 11 8H2"/><path d="M12.6 19.4A2 2 0 1 0 14 16H2"/></svg>
166
+ );
167
+
168
+ export const SnowflakeIcon = (props: React.SVGProps<SVGSVGElement>) => (
169
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="2" x2="22" y1="12" y2="12"/><line x1="12" x2="12" y1="2" y2="22"/><path d="m20 16-4-4 4-4"/><path d="m4 8 4 4-4 4"/><path d="m16 4-4 4-4-4"/><path d="m8 20 4-4 4 4"/></svg>
170
+ );
171
+
172
+ export const SunriseIcon = (props: React.SVGProps<SVGSVGElement>) => (
173
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2v8"/><path d="m4.93 10.93 1.41 1.41"/><path d="M2 18h2"/><path d="M20 18h2"/><path d="m19.07 10.93-1.41 1.41"/><path d="M22 22H2"/><path d="m8 6 4-4 4 4"/><path d="M16 18a4 4 0 0 0-8 0"/></svg>
174
+ );
175
+
176
+ export const LeafIcon = (props: React.SVGProps<SVGSVGElement>) => (
177
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 20A7 7 0 0 1 4 13q0-4.5 4.5-5A7 7 0 0 1 11 20"/><path d="M17 8a4 4 0 0 0-4-4h-2"/></svg>
178
+ );
179
+
180
+ export const StethoscopeIcon = (props: React.SVGProps<SVGSVGElement>) => (
181
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4.8 2.3A.3.3 0 1 0 5 2H4a2 2 0 0 0-2 2v5a6 6 0 0 0 6 6v0a6 6 0 0 0 6-6V4a2 2 0 0 0-2-2h-1a.2.2 0 1 0 .3.3"/><path d="M8 15v1a6 6 0 0 0 6 6v0a6 6 0 0 0 6-6v-4"/><circle cx="20" cy="10" r="2"/></svg>
182
+ );
183
+
184
+ export const BugIcon = (props: React.SVGProps<SVGSVGElement>) => (
185
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m8 2 1.88 1.88"/><path d="M14.12 3.88 16 2"/><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"/><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6"/><path d="M12 20v-4"/><path d="M4 13H2"/><path d="M22 13h-2"/><path d="m6 10 1-2"/><path d="m17 8 1 2"/></svg>
186
+ );
187
+
188
+ export const WrenchIcon = (props: React.SVGProps<SVGSVGElement>) => (
189
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
190
+ );
191
+
192
+ export const LayoutDashboardIcon = (props: React.SVGProps<SVGSVGElement>) => (
193
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg>
194
+ );
195
+
196
+ export const BookUserIcon = (props: React.SVGProps<SVGSVGElement>) => (
197
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v16H6.5a2.5 2.5 0 0 1 0-5H20"/><circle cx="12" cy="8" r="2"/><path d="M15 13a3 3 0 1 0-6 0"/></svg>
198
+ );
199
+
200
+ export const ToolboxIcon = (props: React.SVGProps<SVGSVGElement>) => (
201
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 12h-4"/><path d="M2 12h2"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="M15 6.34V2.05a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2.05v4.29"/><path d="M18.66 9H5.34a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h13.32a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2z"/></svg>
202
+ );
203
+
204
+ export const PlusCircleIcon = (props: React.SVGProps<SVGSVGElement>) => (
205
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="16"/><line x1="8" x2="16" y1="12" y2="12"/></svg>
206
+ );
207
+
208
+ export const Trash2Icon = (props: React.SVGProps<SVGSVGElement>) => (
209
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
210
+ );
211
+
212
+ export const InfoIcon = (props: React.SVGProps<SVGSVGElement>) => (
213
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
214
+ );
215
+
216
+ export const ArrowLeftIcon = (props: React.SVGProps<SVGSVGElement>) => (
217
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>
218
+ );
219
+
220
+ export const HistoryIcon = (props: React.SVGProps<SVGSVGElement>) => (
221
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>
222
+ );
223
+
224
+ export const ImagePlusIcon = (props: React.SVGProps<SVGSVGElement>) => (
225
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7"/><line x1="16" x2="22" y1="5" y2="5"/><line x1="19" x2="19" y1="2" y2="8"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
226
+ );
227
+
228
+ export const GalleryHorizontalIcon = (props: React.SVGProps<SVGSVGElement>) => (
229
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 7v10"/><path d="M6 5v14"/><rect width="12" height="18" x="10" y="3" rx="2"/><path d="M22 7v10"/></svg>
230
+ );
231
+
232
+ export const CameraIcon = (props: React.SVGProps<SVGSVGElement>) => (
233
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></svg>
234
+ );
235
+
236
+ export const PaletteIcon = (props: React.SVGProps<SVGSVGElement>) => (
237
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M10.3 2.2c-1.2 1-2 2.5-2.2 4.2"/><path d="m7.4 8-.8 2.5"/><path d="M14.1 2.2c1.2 1 2 2.5 2.2 4.2"/><path d="m16.6 8 .8 2.5"/><path d="M12 22c-3.3 0-6-2.7-6-6 0-1.5.5-2.8 1.4-3.8.3-.4.7-.7.7-1.2 0-.2-.1-.4-.2-.5-.3-.2-.5-.3-.8-.3H6c-.6 0-1 .4-1 1v1c0 1.1-.9 2-2 2H2"/><path d="M12 22c3.3 0 6-2.7 6-6 0-1.5-.5-2.8-1.4-3.8-.3-.4-.7-.7-.7-1.2 0-.2.1-.4.2-.5.3-.2-.5-.3.8-.3h.5c.6 0 1 .4 1 1v1c0 1.1.9 2 2 2h1"/></svg>
238
+ );
239
+
240
+ export const ScanIcon = (props: React.SVGProps<SVGSVGElement>) => (
241
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M7 12a5 5 0 0 1 5-5"/><path d="M12 17a5 5 0 0 0-5-5"/><path d="M17 12a5 5 0 0 0-5 5"/></svg>
242
+ );
243
+
244
+ export const DownloadIcon = (props: React.SVGProps<SVGSVGElement>) => (
245
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
246
+ );
247
+
248
+ export const SnailIcon = (props: React.SVGProps<SVGSVGElement>) => (
249
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 22c-4-4-4-10-10-10C6 12 6 6 2 2"/><path d="M14 10a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/></svg>
250
+ );
251
+
252
+ export const FilterIcon = (props: React.SVGProps<SVGSVGElement>) => (
253
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
254
+ );
255
+
256
+ export const RootsIcon = (props: React.SVGProps<SVGSVGElement>) => (
257
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 1 9 9h1"/></svg>
258
+ );
259
+
260
+ export const PotRulerIcon = (props: React.SVGProps<SVGSVGElement>) => (
261
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
262
+ <path d="M3 14h18v5H3z"/>
263
+ <path d="M5 14v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3"/>
264
+ <path d="M12 2v2"/>
265
+ <path d="M12 8v2"/>
266
+ <path d="M12 14v-2"/>
267
+ <path d="M5 6H3"/>
268
+ <path d="M5 12H3"/>
269
+ <path d="M21 6h-2"/>
270
+ <path d="M21 12h-2"/>
271
+ </svg>
272
+ );
273
+
274
+ export const SunClockIcon = (props: React.SVGProps<SVGSVGElement>) => (
275
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
276
+ <path d="M12 2v2"/>
277
+ <path d="m4.93 4.93 1.41 1.41"/>
278
+ <path d="M20 12h2"/>
279
+ <path d="m19.07 4.93-1.41 1.41"/>
280
+ <path d="M12 12a8 8 0 0 0-8 8h16a8 8 0 0 0-8-8z"/>
281
+ <path d="M12 12v-2"/>
282
+ </svg>
283
+ );
284
+
285
+ export const BeakerIcon = (props: React.SVGProps<SVGSVGElement>) => (
286
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
287
+ <path d="M4.5 3h15"/><path d="M6 3v16a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V3"/>
288
+ <path d="M6 14h12"/>
289
+ </svg>
290
+ );
291
+
292
+ export const ShovelIcon = (props: React.SVGProps<SVGSVGElement>) => (
293
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
294
+ <path d="M2 22v-5l5-5"/>
295
+ <path d="m7 17 5-5"/>
296
+ <path d="M11 11 9 9"/>
297
+ <path d="M14 4 6 12"/>
298
+ <path d="m18 8 6 6"/>
299
+ <path d="m18 8 4 12 4-4-12-4"/>
300
+ </svg>
301
+ );
302
+
303
+ export const UmbrellaIcon = (props: React.SVGProps<SVGSVGElement>) => (
304
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
305
+ <path d="M22 12a10.06 10.06 1 0 0-20 0Z"/>
306
+ <path d="M12 12v8a2 2 0 0 0 4 0"/>
307
+ <path d="M12 2v1"/>
308
+ </svg>
309
+ );
310
+
311
+ export const SettingsIcon = (props: React.SVGProps<SVGSVGElement>) => (
312
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
313
+ <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 0 2l-.15.08a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1 0-2l.15-.08a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
314
+ <circle cx="12" cy="12" r="3"/>
315
+ </svg>
316
+ );
317
+
318
+ export const ChevronsLeftIcon = (props: React.SVGProps<SVGSVGElement>) => (
319
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
320
+ <path d="m11 17-5-5 5-5"/><path d="m18 17-5-5 5-5"/>
321
+ </svg>
322
+ );
323
+
324
+ export const LogOutIcon = (props: React.SVGProps<SVGSVGElement>) => (
325
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
326
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>
327
+ </svg>
328
+ );
329
+
330
+ export const KeyIcon = (props: React.SVGProps<SVGSVGElement>) => (
331
+ <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
332
+ <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
333
+ </svg>
334
+ );
config.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // IMPORTANT: This file contains configuration variables.
2
+
3
+ // --- Ko-fi Configuration ---
4
+ // This is the link to your Ko-fi page where users can purchase the app.
5
+ export const KOFI_PAGE_URL = 'https://ko-fi.com/your_page_here';
6
+
7
+ // These are the secret credentials you will provide to users after they purchase.
8
+ // You should generate a strong, unique password.
9
+ export const KOFI_LOGIN_NAME = 'yuki-bonsai-master';
10
+ export const KOFI_SECRET_PASSWORD = 'a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8';
11
+
12
+ // The Google Client ID is no longer used for login but may be kept for other Google service integrations if needed.
13
+ export const GOOGLE_CLIENT_ID = '605727770560-dijtutkchf855kjdqokmvhb6mrum0cm9.apps.googleusercontent.com';
context/AuthContext.tsx ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { createContext, useState, useEffect, useCallback } from 'react';
2
+ import type { UserProfile } from '../types';
3
+ import { KOFI_LOGIN_NAME, KOFI_SECRET_PASSWORD } from '../config';
4
+
5
+ // Keys for localStorage
6
+ const CUSTOM_NAME_KEY = 'yuki-app-login-name';
7
+ const CUSTOM_SECRET_KEY = 'yuki-app-login-secret';
8
+ const SESSION_KEY = 'yuki-user-session';
9
+
10
+ interface IAuthContext {
11
+ user: UserProfile | null;
12
+ isLoading: boolean;
13
+ login: (name: string, secret: string) => boolean;
14
+ logout: () => void;
15
+ updateCredentials: (currentSecret: string, newName: string, newSecret: string) => { success: boolean; message: string };
16
+ }
17
+
18
+ export const AuthContext = createContext<IAuthContext>({
19
+ user: null,
20
+ isLoading: true,
21
+ login: () => false,
22
+ logout: () => {},
23
+ updateCredentials: () => ({ success: false, message: 'Not implemented' }),
24
+ });
25
+
26
+ const createAuthenticatedUser = (name: string): UserProfile => ({
27
+ id: `user-${name}`,
28
+ name: "Bonsai Master",
29
+ email: '', // Not needed for this auth model
30
+ picture: '', // Not needed for this auth model
31
+ });
32
+
33
+
34
+ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
35
+ const [user, setUser] = useState<UserProfile | null>(null);
36
+ const [isLoading, setIsLoading] = useState(true);
37
+ const storageKey = 'yuki-user-session'; // Keep existing name for session for backward compatibility
38
+
39
+ useEffect(() => {
40
+ try {
41
+ const session = window.localStorage.getItem(storageKey);
42
+ if (session) {
43
+ const sessionUser = JSON.parse(session);
44
+ setUser(sessionUser);
45
+ }
46
+ } catch (error) {
47
+ console.error("Failed to parse user session:", error);
48
+ window.localStorage.removeItem(storageKey);
49
+ } finally {
50
+ setIsLoading(false);
51
+ }
52
+ }, [storageKey]);
53
+
54
+ const login = useCallback((name: string, secret: string): boolean => {
55
+ const customName = window.localStorage.getItem(CUSTOM_NAME_KEY);
56
+ const customSecret = window.localStorage.getItem(CUSTOM_SECRET_KEY);
57
+
58
+ const effectiveName = customName || KOFI_LOGIN_NAME;
59
+ const effectiveSecret = customSecret || KOFI_SECRET_PASSWORD;
60
+
61
+ if (name === effectiveName && secret === effectiveSecret) {
62
+ const authenticatedUser = createAuthenticatedUser(name);
63
+ try {
64
+ window.localStorage.setItem(storageKey, JSON.stringify(authenticatedUser));
65
+ setUser(authenticatedUser);
66
+ return true;
67
+ } catch (error) {
68
+ console.error("Failed to save user session:", error);
69
+ return false;
70
+ }
71
+ }
72
+ return false;
73
+ }, [storageKey]);
74
+
75
+ const logout = useCallback(() => {
76
+ try {
77
+ window.localStorage.removeItem(storageKey);
78
+ setUser(null);
79
+ } catch (error) {
80
+ console.error("Failed to clear user session:", error);
81
+ }
82
+ }, [storageKey]);
83
+
84
+ const updateCredentials = useCallback((currentSecret: string, newName: string, newSecret: string): { success: boolean; message: string } => {
85
+ const customSecret = window.localStorage.getItem(CUSTOM_SECRET_KEY);
86
+ const effectiveSecret = customSecret || KOFI_SECRET_PASSWORD;
87
+
88
+ if (currentSecret !== effectiveSecret) {
89
+ return { success: false, message: "Current secret password is incorrect." };
90
+ }
91
+
92
+ if (!newName.trim() || !newSecret.trim()) {
93
+ return { success: false, message: "New login name and secret cannot be empty." };
94
+ }
95
+
96
+ try {
97
+ window.localStorage.setItem(CUSTOM_NAME_KEY, newName);
98
+ window.localStorage.setItem(CUSTOM_SECRET_KEY, newSecret);
99
+ return { success: true, message: "Credentials updated successfully! You will need to use these next time you log in." };
100
+ } catch (error) {
101
+ console.error("Failed to update credentials:", error);
102
+ return { success: false, message: "Failed to save new credentials due to a storage error." };
103
+ }
104
+ }, []);
105
+
106
+ return (
107
+ <AuthContext.Provider value={{ user, isLoading, login, logout, updateCredentials }}>
108
+ {children}
109
+ </AuthContext.Provider>
110
+ );
111
+ };
hooks/useLocalStorage.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+
3
+ // A hook for persisting state to localStorage, now user-agnostic.
4
+ export function useLocalStorage<T>(key: string, initialValue: T): [T, React.Dispatch<React.SetStateAction<T>>] {
5
+
6
+ // Create a static key.
7
+ const storageKey = `yuki-app-${key}`;
8
+
9
+ const [storedValue, setStoredValue] = useState<T>(() => {
10
+ try {
11
+ if (typeof window === 'undefined') return initialValue;
12
+ const item = window.localStorage.getItem(storageKey);
13
+ return item ? JSON.parse(item) : initialValue;
14
+ } catch (error) {
15
+ console.error(error);
16
+ return initialValue;
17
+ }
18
+ });
19
+
20
+ useEffect(() => {
21
+ try {
22
+ const item = window.localStorage.getItem(storageKey);
23
+ if (item) {
24
+ setStoredValue(JSON.parse(item));
25
+ }
26
+ } catch (error) {
27
+ console.error(error);
28
+ }
29
+ }, [storageKey]);
30
+
31
+
32
+ const setValue: React.Dispatch<React.SetStateAction<T>> = (value) => {
33
+ try {
34
+ const valueToStore = value instanceof Function ? value(storedValue) : value;
35
+ setStoredValue(valueToStore);
36
+ if (typeof window !== 'undefined') {
37
+ window.localStorage.setItem(storageKey, JSON.stringify(valueToStore));
38
+ }
39
+ } catch (error) {
40
+ console.error(error);
41
+ }
42
+ };
43
+ return [storedValue, setValue];
44
+ }
index.html CHANGED
@@ -1,19 +1,39 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Yuki - Your AI Bonsai Sensei</title>
7
+ <link rel="manifest" href="/manifest.json">
8
+ <meta name="theme-color" content="#f5f5f4">
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ <style>
11
+ /* Custom font for a more serene feel */
12
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
13
+ html, body, #root {
14
+ height: 100%;
15
+ }
16
+ body {
17
+ font-family: 'Inter', sans-serif;
18
+ }
19
+ </style>
20
+ <script type="importmap">
21
+ {
22
+ "imports": {
23
+ "react": "https://esm.sh/react@^19.1.0",
24
+ "react-dom/": "https://esm.sh/react-dom@^19.1.0/",
25
+ "react/": "https://esm.sh/react@^19.1.0/",
26
+ "@google/genai": "https://esm.sh/@google/genai@^1.9.0",
27
+ "suncalc": "https://esm.sh/[email protected]",
28
+ "jwt-decode": "https://esm.sh/[email protected]"
29
+ }
30
+ }
31
+ </script>
32
+ <script src="https://accounts.google.com/gsi/client" async></script>
33
+ <link rel="stylesheet" href="/index.css">
34
+ </head>
35
+ <body class="bg-stone-50 text-stone-800">
36
+ <div id="root"></div>
37
+ <script type="module" src="/index.tsx"></script>
38
+ </body>
39
+ </html>
index.tsx ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import ReactDOM from 'react-dom/client';
4
+ import App from './App';
5
+
6
+ const rootElement = document.getElementById('root');
7
+ if (!rootElement) {
8
+ throw new Error("Could not find root element to mount to");
9
+ }
10
+
11
+ const root = ReactDOM.createRoot(rootElement);
12
+ root.render(
13
+ <React.StrictMode>
14
+ <App />
15
+ </React.StrictMode>
16
+ );
manifest.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "short_name": "Yuki Bonsai",
3
+ "name": "Yuki - Your AI Bonsai Sensei",
4
+ "icons": [
5
+ {
6
+ "src": "icon-192.png",
7
+ "type": "image/png",
8
+ "sizes": "192x192"
9
+ },
10
+ {
11
+ "src": "icon-512.png",
12
+ "type": "image/png",
13
+ "sizes": "512x512"
14
+ }
15
+ ],
16
+ "start_url": ".",
17
+ "display": "standalone",
18
+ "theme_color": "#f5f5f4",
19
+ "background_color": "#f5f5f4",
20
+ "description": "Yuki is your AI Bonsai Sensei, helping you cultivate the ancient art of bonsai with modern intelligence."
21
+ }
metadata.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Yuki - Your AI Bonsai Sensei",
3
+ "description": "Yuki is your AI Bonsai Sensei, helping you cultivate the ancient art of bonsai with modern intelligence. Get photo-based health diagnostics, climate-adjusted task scheduling, and track your tree's growth with an AI-powered growth diary.",
4
+ "requestFramePermissions": [
5
+ "camera"
6
+ ]
7
+ }
package.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "yuki---your-ai-bonsai-sensei",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "react": "^19.1.0",
13
+ "react-dom": "^19.1.0",
14
+ "@google/genai": "^1.9.0",
15
+ "suncalc": "1.9.0",
16
+ "jwt-decode": "4.0.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^22.14.0",
20
+ "typescript": "~5.7.2",
21
+ "vite": "^6.2.0"
22
+ }
23
+ }
service-worker.js ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const CACHE_NAME = 'yuki-bonsai-cache-v1';
2
+ const APP_SHELL_URLS = [
3
+ '/',
4
+ '/index.html'
5
+ ];
6
+
7
+ self.addEventListener('install', event => {
8
+ event.waitUntil(
9
+ caches.open(CACHE_NAME)
10
+ .then(cache => cache.addAll(APP_SHELL_URLS))
11
+ .then(() => self.skipWaiting())
12
+ );
13
+ });
14
+
15
+ self.addEventListener('activate', event => {
16
+ event.waitUntil(
17
+ caches.keys().then(cacheNames => {
18
+ return Promise.all(
19
+ cacheNames.map(cacheName => {
20
+ if (cacheName !== CACHE_NAME) {
21
+ return caches.delete(cacheName);
22
+ }
23
+ })
24
+ );
25
+ }).then(() => self.clients.claim())
26
+ );
27
+ });
28
+
29
+ self.addEventListener('fetch', event => {
30
+ if (event.request.method !== 'GET') {
31
+ return;
32
+ }
33
+
34
+ // For navigation requests, use a network-first strategy to get latest HTML.
35
+ if (event.request.mode === 'navigate') {
36
+ event.respondWith(
37
+ fetch(event.request)
38
+ .catch(() => caches.match('/index.html'))
39
+ );
40
+ return;
41
+ }
42
+
43
+ // For all other requests (JS, CSS, images from esm.sh), use a cache-first strategy.
44
+ event.respondWith(
45
+ caches.match(event.request).then(cachedResponse => {
46
+ if (cachedResponse) {
47
+ return cachedResponse;
48
+ }
49
+
50
+ return fetch(event.request).then(response => {
51
+ // Don't cache opaque responses (from CDNs without CORS) or non-200 responses.
52
+ if (!response || response.status !== 200 || response.type === 'opaque') {
53
+ return response;
54
+ }
55
+
56
+ const responseToCache = response.clone();
57
+ caches.open(CACHE_NAME)
58
+ .then(cache => {
59
+ cache.put(event.request, responseToCache);
60
+ });
61
+
62
+ return response;
63
+ });
64
+ })
65
+ );
66
+ });
services/geminiService.ts ADDED
@@ -0,0 +1,917 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+
4
+ import { GoogleGenAI, Type, Part } from "@google/genai";
5
+ import type { BonsaiAnalysis, PestLibraryEntry, SeasonalGuide, ToolRecommendation, MaintenanceTips, DiaryAIAnalysis, HealthCheckResult, SpeciesIdentificationResult, StylingBlueprint, SoilAnalysis, ProtectionProfile } from '../types';
6
+
7
+ let ai: GoogleGenAI | null = null;
8
+
9
+ // Function to initialize or re-initialize the AI client
10
+ export function reinitializeAI() {
11
+ const apiKey = window.localStorage.getItem('gemini-api-key');
12
+ if (apiKey) {
13
+ try {
14
+ ai = new GoogleGenAI({ apiKey });
15
+ } catch (e) {
16
+ console.error("Failed to initialize GoogleGenAI, likely due to an invalid API key format.", e);
17
+ ai = null;
18
+ }
19
+ } else {
20
+ ai = null;
21
+ }
22
+ }
23
+
24
+ // Function to check if the AI is configured
25
+ export function isAIConfigured(): boolean {
26
+ return ai !== null;
27
+ }
28
+
29
+ // Initialize on load
30
+ reinitializeAI();
31
+
32
+
33
+ const protectionProfileSchema = {
34
+ type: Type.OBJECT,
35
+ properties: {
36
+ minTempC: { type: Type.INTEGER, description: "The minimum safe temperature in Celsius the tree can tolerate without protection." },
37
+ maxTempC: { type: Type.INTEGER, description: "The maximum safe temperature in Celsius before the tree might suffer from heat stress." },
38
+ maxWindKph: { type: Type.INTEGER, description: "The maximum sustained wind speed in km/h the tree can handle before risking damage or dehydration." },
39
+ },
40
+ required: ["minTempC", "maxTempC", "maxWindKph"]
41
+ };
42
+
43
+ const pestLibrarySchema = {
44
+ type: Type.ARRAY,
45
+ description: "A library of common pests and diseases for this species and region.",
46
+ items: {
47
+ type: Type.OBJECT,
48
+ properties: {
49
+ name: { type: Type.STRING },
50
+ type: { type: Type.STRING, enum: ['Pest', 'Disease'] },
51
+ description: { type: Type.STRING },
52
+ symptoms: { type: Type.ARRAY, items: { type: Type.STRING }},
53
+ treatment: {
54
+ type: Type.OBJECT,
55
+ properties: {
56
+ organic: { type: Type.STRING },
57
+ chemical: { type: Type.STRING }
58
+ },
59
+ required: ["organic", "chemical"]
60
+ }
61
+ },
62
+ required: ["name", "type", "description", "symptoms", "treatment"]
63
+ }
64
+ };
65
+
66
+ const seasonalGuideSchema = {
67
+ type: Type.ARRAY,
68
+ description: "A high-level guide for all four seasons.",
69
+ items: {
70
+ type: Type.OBJECT,
71
+ properties: {
72
+ season: { type: Type.STRING, enum: ['Spring', 'Summer', 'Autumn', 'Winter'] },
73
+ summary: { type: Type.STRING },
74
+ tasks: {
75
+ type: Type.ARRAY,
76
+ items: {
77
+ type: Type.OBJECT,
78
+ properties: {
79
+ task: { type: Type.STRING },
80
+ importance: { type: Type.STRING, enum: ['High', 'Medium', 'Low'] }
81
+ },
82
+ required: ["task", "importance"]
83
+ }
84
+ }
85
+ },
86
+ required: ["season", "summary", "tasks"]
87
+ }
88
+ };
89
+
90
+ const toolRecommendationsSchema = {
91
+ type: Type.ARRAY,
92
+ description: "A list of recommended tools and supplies.",
93
+ items: {
94
+ type: Type.OBJECT,
95
+ properties: {
96
+ name: { type: Type.STRING },
97
+ category: { type: Type.STRING, enum: ['Cutting', 'Wiring', 'Repotting', 'General Care'] },
98
+ description: { type: Type.STRING },
99
+ level: { type: Type.STRING, enum: ['Essential', 'Recommended', 'Advanced'] }
100
+ },
101
+ required: ["name", "category", "description", "level"]
102
+ }
103
+ };
104
+
105
+ const maintenanceTipsSchema = {
106
+ type: Type.OBJECT,
107
+ properties: {
108
+ sharpening: { type: Type.STRING, description: "A concise guide on how to sharpen this specific tool. Mention needed supplies like whetstones or files." },
109
+ cleaning: { type: Type.STRING, description: "Instructions for cleaning the tool after use to prevent disease and rust. Mention things like sap remover." },
110
+ storage: { type: Type.STRING, description: "Best practices for storing the tool to ensure its longevity, like oiling and proper placement." }
111
+ },
112
+ required: ["sharpening", "cleaning", "storage"]
113
+ };
114
+
115
+ const diaryAIAnalysisSchema = {
116
+ type: Type.OBJECT,
117
+ properties: {
118
+ summary: { type: Type.STRING, description: "A concise summary of observed changes since the last log entry. Note new growth, color changes, or any potential issues." },
119
+ healthChange: { type: Type.INTEGER, description: "An estimated integer value of the health change since the last photo. Can be positive, negative, or zero." },
120
+ suggestions: { type: Type.ARRAY, items: { type: Type.STRING }, description: "1-2 actionable suggestions based on the observations." },
121
+ },
122
+ required: ["summary"]
123
+ };
124
+
125
+ const healthCheckResultSchema = {
126
+ type: Type.OBJECT,
127
+ properties: {
128
+ probableCause: { type: Type.STRING, description: "The most likely specific cause of the issue (e.g., 'Spider Mite Infestation', 'Root Rot due to Overwatering', 'Iron Chlorosis')." },
129
+ confidence: { type: Type.STRING, enum: ['High', 'Medium', 'Low'], description: "The AI's confidence level in this diagnosis." },
130
+ explanation: { type: Type.STRING, description: "A detailed explanation of why this diagnosis was reached, referencing visual cues from the image and the user's problem description." },
131
+ isPest: { type: Type.BOOLEAN, description: "True if the primary cause is a pest." },
132
+ isDisease: { type: Type.BOOLEAN, description: "True if the primary cause is a fungal, bacterial, or viral disease." },
133
+ treatmentPlan: {
134
+ type: Type.ARRAY,
135
+ description: "A step-by-step, actionable treatment plan.",
136
+ items: {
137
+ type: Type.OBJECT,
138
+ properties: {
139
+ step: { type: Type.INTEGER },
140
+ action: { type: Type.STRING, description: "A short, clear title for the step (e.g., 'Isolate the Tree', 'Apply Neem Oil')." },
141
+ details: { type: Type.STRING, description: "Detailed instructions on how to perform the action." }
142
+ },
143
+ required: ["step", "action", "details"]
144
+ }
145
+ },
146
+ organicAlternatives: { type: Type.STRING, description: "A summary of organic or non-chemical treatment options available." },
147
+ preventativeMeasures: { type: Type.STRING, description: "Advice on how to prevent this issue from recurring in the future." }
148
+ },
149
+ required: ["probableCause", "confidence", "explanation", "isPest", "isDisease", "treatmentPlan", "organicAlternatives", "preventativeMeasures"]
150
+ };
151
+
152
+ const speciesIdentificationSchema = {
153
+ type: Type.OBJECT,
154
+ properties: {
155
+ identifications: {
156
+ type: Type.ARRAY,
157
+ description: "An array of possible species identifications, ordered from most to least likely.",
158
+ items: {
159
+ type: Type.OBJECT,
160
+ properties: {
161
+ commonName: { type: Type.STRING },
162
+ scientificName: { type: Type.STRING },
163
+ confidence: { type: Type.STRING, enum: ['High', 'Medium', 'Low'] },
164
+ reasoning: { type: Type.STRING, description: "Why the AI made this identification based on visual cues." },
165
+ generalCareSummary: { type: Type.STRING, description: "A very brief, one-paragraph summary of the most critical care aspects for this species (light, water, soil)." }
166
+ },
167
+ required: ["commonName", "scientificName", "confidence", "reasoning", "generalCareSummary"]
168
+ }
169
+ }
170
+ },
171
+ required: ["identifications"]
172
+ };
173
+
174
+ const soilAnalysisSchema = {
175
+ type: Type.OBJECT,
176
+ properties: {
177
+ components: {
178
+ type: Type.ARRAY,
179
+ description: "The identified components of the soil mix and their estimated percentages.",
180
+ items: {
181
+ type: Type.OBJECT,
182
+ properties: {
183
+ name: { type: Type.STRING, enum: ['Akadama', 'Pumice', 'Lava Rock', 'Organic Compost', 'Kiryu', 'Pine Bark', 'Diatomaceous Earth', 'Sand', 'Grit', 'Other'] },
184
+ percentage: { type: Type.INTEGER },
185
+ },
186
+ required: ["name", "percentage"]
187
+ }
188
+ },
189
+ drainageRating: { type: Type.STRING, enum: ['Poor', 'Average', 'Good', 'Excellent'] },
190
+ waterRetention: { type: Type.STRING, enum: ['Low', 'Medium', 'High'] },
191
+ suitabilityAnalysis: { type: Type.STRING, description: "Analysis of the soil's suitability for the specified species and location." },
192
+ improvementSuggestions: { type: Type.STRING, description: "Actionable suggestions to improve the soil mix." }
193
+ },
194
+ required: ["components", "drainageRating", "waterRetention", "suitabilityAnalysis", "improvementSuggestions"]
195
+ };
196
+
197
+ const stylingBlueprintSchema = {
198
+ type: Type.OBJECT,
199
+ properties: {
200
+ summary: { type: Type.STRING, description: "A concise summary of the overall styling strategy." },
201
+ canvas: {
202
+ type: Type.OBJECT,
203
+ properties: {
204
+ width: { type: Type.INTEGER, description: "The width of the canvas the coordinates are based on. Always use 1000." },
205
+ height: { type: Type.INTEGER, description: "The height of the canvas the coordinates are based on. Always use 1000." },
206
+ },
207
+ required: ["width", "height"]
208
+ },
209
+ annotations: {
210
+ type: Type.ARRAY,
211
+ items: {
212
+ type: Type.OBJECT,
213
+ properties: {
214
+ type: { type: Type.STRING, enum: ['PRUNE_LINE', 'WIRE_DIRECTION', 'REMOVE_BRANCH', 'FOLIAGE_REFINEMENT', 'JIN_SHARI', 'TRUNK_LINE', 'EXPOSE_ROOT'] },
215
+ points: {
216
+ type: Type.ARRAY,
217
+ description: "An array of {x, y} coordinates for drawing lines or polygons. Required for most types except those using 'path'.",
218
+ items: {
219
+ type: Type.OBJECT,
220
+ properties: {
221
+ x: { type: Type.INTEGER },
222
+ y: { type: Type.INTEGER }
223
+ },
224
+ required: ["x", "y"]
225
+ }
226
+ },
227
+ path: { type: Type.STRING, description: "An SVG path data string (e.g., 'M 10 10 Q 20 20 30 10'). Use for complex curves like wiring directions or trunk lines." },
228
+ label: { type: Type.STRING, description: "A concise, user-facing label explaining the annotation (e.g., 'Prune here to shorten branch')." }
229
+ },
230
+ required: ["type", "label"]
231
+ }
232
+ }
233
+ },
234
+ required: ["summary", "canvas", "annotations"]
235
+ };
236
+
237
+
238
+ const bonsaiAnalysisSchema = {
239
+ type: Type.OBJECT,
240
+ properties: {
241
+ species: { type: Type.STRING, description: "The scientific or common name of the bonsai species identified."},
242
+ healthAssessment: {
243
+ type: Type.OBJECT,
244
+ description: "A detailed assessment of the bonsai's health.",
245
+ properties: {
246
+ overallHealth: { type: Type.STRING, description: "A summary of the tree's health (e.g., 'Excellent', 'Needs Attention', 'Stressed')." },
247
+ healthScore: { type: Type.INTEGER, description: "A numerical health score from 1 to 100, where 100 is perfect health." },
248
+ observations: { type: Type.ARRAY, items: { type: Type.STRING }, description: "Specific visual observations from the image." },
249
+ foliageHealth: { type: Type.STRING, description: "Analysis of the leaves/needles (color, density, size)." },
250
+ trunkAndNebariHealth: { type: Type.STRING, description: "Analysis of the trunk, bark, and surface roots (nebari)." },
251
+ potAndSoilHealth: { type: Type.STRING, description: "Analysis of the pot condition and visible soil surface." },
252
+ },
253
+ required: ["overallHealth", "healthScore", "observations", "foliageHealth", "trunkAndNebariHealth", "potAndSoilHealth"]
254
+ },
255
+ careSchedule: {
256
+ type: Type.ARRAY,
257
+ description: "A 4-week care schedule adjusted for species and climate.",
258
+ items: {
259
+ type: Type.OBJECT,
260
+ properties: {
261
+ week: { type: Type.INTEGER, description: "The week number (1-4)." },
262
+ task: { type: Type.STRING, description: "The primary task for the week (e.g., 'Watering', 'Fertilizing', 'Pruning')." },
263
+ details: { type: Type.STRING, description: "Specific instructions for the task." },
264
+ toolsNeeded: { type: Type.ARRAY, items: { type: Type.STRING }, description: "A list of tools needed for the task." },
265
+ },
266
+ required: ["week", "task", "details"]
267
+ }
268
+ },
269
+ pestAndDiseaseAlerts: {
270
+ type: Type.ARRAY,
271
+ description: "Potential pests or diseases to watch for, with severity assessment.",
272
+ items: {
273
+ type: Type.OBJECT,
274
+ properties: {
275
+ pestOrDisease: { type: Type.STRING },
276
+ symptoms: { type: Type.STRING },
277
+ treatment: { type: Type.STRING },
278
+ severity: { type: Type.STRING, enum: ["Low", "Medium", "High"] }
279
+ },
280
+ required: ["pestOrDisease", "symptoms", "treatment", "severity"]
281
+ }
282
+ },
283
+ stylingSuggestions: {
284
+ type: Type.ARRAY,
285
+ description: "Actionable advice for styling the bonsai.",
286
+ items: {
287
+ type: Type.OBJECT,
288
+ properties: {
289
+ technique: { type: Type.STRING, enum: ['Pruning', 'Wiring', 'Shaping'] },
290
+ description: { type: Type.STRING, description: "Detailed 'how-to' for the technique." },
291
+ area: { type: Type.STRING, description: "The part of the tree to apply the technique to." },
292
+ },
293
+ required: ["technique", "description", "area"]
294
+ }
295
+ },
296
+ environmentalFactors: {
297
+ type: Type.OBJECT,
298
+ description: "Ideal environmental conditions for the species.",
299
+ properties: {
300
+ idealLight: { type: Type.STRING, description: "e.g., '6-8 hours of direct morning sun, afternoon shade'" },
301
+ idealHumidity: { type: Type.STRING, description: "e.g., 'Prefers 50-70% humidity, mist daily if lower'" },
302
+ temperatureRange: { type: Type.STRING, description: "e.g., 'Tolerates 50-85°F (10-30°C)'" },
303
+ },
304
+ required: ["idealLight", "idealHumidity", "temperatureRange"]
305
+ },
306
+ wateringAnalysis: {
307
+ type: Type.OBJECT,
308
+ description: "Specific watering advice.",
309
+ properties: {
310
+ frequency: { type: Type.STRING, description: "How often to water, e.g., 'Every 1-3 days, check soil first'" },
311
+ method: { type: Type.STRING, description: "Recommended watering method, e.g., 'Immersion or top-watering until drains'" },
312
+ notes: { type: Type.STRING, description: "Additional notes, e.g., 'Allow soil to become slightly dry between waterings. Use filtered water if possible.'" }
313
+ },
314
+ required: ["frequency", "method", "notes"]
315
+ },
316
+ knowledgeNuggets: {
317
+ type: Type.ARRAY,
318
+ description: "Three interesting, little-known facts about this specific bonsai species.",
319
+ items: { type: Type.STRING }
320
+ },
321
+ estimatedAge: {
322
+ type: Type.STRING,
323
+ description: "An estimated age range of the bonsai based on its trunk, nebari, and branch structure."
324
+ },
325
+ fertilizerRecommendations: {
326
+ type: Type.ARRAY,
327
+ description: "A seasonal fertilization plan.",
328
+ items: {
329
+ type: Type.OBJECT,
330
+ properties: {
331
+ phase: { type: Type.STRING, enum: ['Spring Growth', 'Summer Maintenance', 'Autumn Preparation', 'Winter Dormancy'] },
332
+ type: { type: Type.STRING, description: "e.g., 'Balanced (e.g., 10-10-10)'" },
333
+ frequency: { type: Type.STRING },
334
+ notes: { type: Type.STRING }
335
+ },
336
+ required: ["phase", "type", "frequency", "notes"]
337
+ }
338
+ },
339
+ soilRecipe: {
340
+ type: Type.OBJECT,
341
+ description: "A precise soil mixture recipe.",
342
+ properties: {
343
+ components: {
344
+ type: Type.ARRAY,
345
+ items: {
346
+ type: Type.OBJECT,
347
+ properties: {
348
+ name: { type: Type.STRING, enum: ['Akadama', 'Pumice', 'Lava Rock', 'Organic Compost', 'Kiryu', 'Other'] },
349
+ percentage: { type: Type.INTEGER },
350
+ notes: { type: Type.STRING }
351
+ },
352
+ required: ["name", "percentage", "notes"]
353
+ }
354
+ },
355
+ rationale: { type: Type.STRING, description: "Justification for this specific soil mix." }
356
+ },
357
+ required: ["components", "rationale"]
358
+ },
359
+ potSuggestion: {
360
+ type: Type.OBJECT,
361
+ description: "Recommendation for an appropriate pot.",
362
+ properties: {
363
+ style: { type: Type.STRING, description: "e.g., 'Unglazed Rectangular'" },
364
+ size: { type: Type.STRING, description: "Recommended size relative to the tree." },
365
+ colorPalette: { type: Type.STRING },
366
+ rationale: { type: Type.STRING, description: "Aesthetic and horticultural reasoning." }
367
+ },
368
+ required: ["style", "size", "colorPalette", "rationale"]
369
+ },
370
+ seasonalGuide: seasonalGuideSchema,
371
+ diagnostics: {
372
+ type: Type.ARRAY,
373
+ description: "Advanced diagnostics for potential underlying issues.",
374
+ items: {
375
+ type: Type.OBJECT,
376
+ properties: {
377
+ issue: { type: Type.STRING },
378
+ confidence: { type: Type.STRING, enum: ['High', 'Medium', 'Low'] },
379
+ symptoms: { type: Type.STRING, description: "Symptoms to watch for."},
380
+ solution: { type: Type.STRING, description: "Preventative or corrective solutions."}
381
+ },
382
+ required: ["issue", "confidence", "symptoms", "solution"]
383
+ }
384
+ },
385
+ pestLibrary: pestLibrarySchema,
386
+ toolRecommendations: toolRecommendationsSchema
387
+ },
388
+ required: [
389
+ "species", "healthAssessment", "careSchedule", "pestAndDiseaseAlerts",
390
+ "stylingSuggestions", "environmentalFactors", "wateringAnalysis", "knowledgeNuggets",
391
+ "estimatedAge", "fertilizerRecommendations", "soilRecipe", "potSuggestion",
392
+ "seasonalGuide", "diagnostics", "pestLibrary", "toolRecommendations"
393
+ ]
394
+ };
395
+
396
+ async function generate(prompt: string, schema: any) {
397
+ if (!isAIConfigured()) {
398
+ throw new Error("AI is not configured. Please set your API key in Settings.");
399
+ }
400
+ const response = await ai!.models.generateContent({
401
+ model: 'gemini-2.5-flash',
402
+ contents: { parts: [{ text: prompt }] },
403
+ config: {
404
+ responseMimeType: "application/json",
405
+ responseSchema: schema
406
+ }
407
+ });
408
+ const jsonText = response.text.trim();
409
+ if (!jsonText) {
410
+ console.error("Gemini API returned an empty response.");
411
+ return null;
412
+ }
413
+ return JSON.parse(jsonText);
414
+ }
415
+
416
+ function handleAIError(error: any): null {
417
+ console.error("Error communicating with AI:", error);
418
+ if (error instanceof Error && (error.message.includes('API key not valid') || error.message.includes('permission to access') || error.message.includes('400'))) {
419
+ throw new Error("Your Gemini API key is not valid or has incorrect permissions. Please check it in Settings.");
420
+ }
421
+ return null;
422
+ }
423
+
424
+
425
+ export const analyzeBonsai = async (
426
+ imageBase64: string,
427
+ species: string,
428
+ location: string
429
+ ): Promise<BonsaiAnalysis | null> => {
430
+ if (!isAIConfigured()) {
431
+ throw new Error("AI is not configured. Please set your API key in Settings.");
432
+ }
433
+ try {
434
+ const prompt = `You are 'Yuki', a world-renowned bonsai master with 50 years of experience. Analyze the provided image of a bonsai, which the user says is a ${species}, located in/near ${location}. Your analysis MUST be comprehensive, scientifically accurate, and deeply insightful, tailored to both the species and the local climate.
435
+
436
+ Based on the visual evidence, regional climate data for ${location}, and your expert knowledge of ${species} bonsai, provide a complete and structured analysis covering all the following points:
437
+
438
+ 1. **Confirm Species:** First, confirm if the image looks like a ${species}. If not, identify the correct species. Populate the 'species' field with the correct name.
439
+ 2. **Health Assessment:** Conduct a thorough health diagnosis. Provide a numerical score (1-100). Detail the health of foliage, trunk/nebari, and pot/soil.
440
+ 3. **Care Schedule:** Create a highly specific 4-week care plan. Include tasks, detailed instructions, and a list of tools for each week.
441
+ 4. **Pest/Disease Alerts:** Identify any *active* pests or diseases on the tree. If none, list preventative measures.
442
+ 5. **Styling Advice:** Provide at least two actionable styling suggestions (pruning, wiring, shaping) suitable for the tree's current state and species characteristics.
443
+ 6. **Environment:** Specify the ideal light, humidity, and temperature for this species.
444
+ 7. **Watering:** Give a detailed watering analysis - frequency, method, and important notes.
445
+ 8. **Age Estimation:** Estimate the tree's age range based on trunk thickness, bark texture, and nebari.
446
+ 9. **Knowledge Nuggets:** Provide three fascinating, little-known facts about this species. These should be 'insider' knowledge that a true enthusiast would appreciate.
447
+ 10. **Fertilizer Plan:** Based on the species, health, and time of year, provide a seasonal fertilizer plan. Specify N-P-K ratios or fertilizer type (e.g., solid organic, liquid chemical), and frequency for each season.
448
+ 11. **Soil Recipe:** Recommend a precise soil mixture recipe in percentages (e.g., 40% Akadama, 30% Pumice, 20% Lava Rock, 10% Compost). Justify why this mix is ideal for this species' drainage and water retention needs.
449
+ 12. **Pot Selection:** Suggest an appropriate pot. Consider style (e.g., formal upright, cascade), material (e.g., unglazed ceramic), color, and size relative to the tree. Explain the aesthetic and horticultural reasoning.
450
+ 13. **Full-Year Seasonal Guide:** Provide a high-level guide for all four seasons (Spring, Summer, Autumn, Winter) for this tree in ${location}. For each season, summarize the main goals and list key tasks with their importance level (High, Medium, Low).
451
+ 14. **Advanced Diagnostics:** Based on subtle cues in the image and common issues for this species/climate, identify 2-3 potential underlying problems (e.g., root rot, nutrient deficiency, salt buildup) even if not in a critical state yet. Provide confidence level, symptoms to watch for, and preventative solutions.
452
+ 15. **Pest & Disease Library:** Generate a mini-library of the 3 most common pests and diseases for this species in ${location}. This is for general knowledge. For each, provide a description, symptoms, and both organic and chemical treatment options.
453
+ 16. **Tool & Supply Recommendations:** Provide a list of essential and advanced tools and supplies for this tree. Categorize them (e.g., Cutting, Wiring, Repotting, General Care) and explain why each is needed. Mark each as 'Essential', 'Recommended', or 'Advanced'.`;
454
+
455
+ const imagePart = {
456
+ inlineData: {
457
+ mimeType: 'image/jpeg',
458
+ data: imageBase64,
459
+ },
460
+ };
461
+
462
+ const textPart = { text: prompt };
463
+
464
+ const response = await ai!.models.generateContent({
465
+ model: 'gemini-2.5-flash',
466
+ contents: { parts: [imagePart, textPart] },
467
+ config: {
468
+ responseMimeType: "application/json",
469
+ responseSchema: bonsaiAnalysisSchema
470
+ }
471
+ });
472
+
473
+ const jsonText = response.text.trim();
474
+ if (!jsonText) {
475
+ console.error("Gemini API returned an empty response.");
476
+ return null;
477
+ }
478
+
479
+ return JSON.parse(jsonText) as BonsaiAnalysis;
480
+ } catch (error) {
481
+ return handleAIError(error);
482
+ }
483
+ };
484
+
485
+ export const analyzeFollowUp = async (
486
+ imageBase64: string,
487
+ previousAnalysis: BonsaiAnalysis,
488
+ species: string,
489
+ location: string
490
+ ): Promise<BonsaiAnalysis | null> => {
491
+ if (!isAIConfigured()) {
492
+ throw new Error("AI is not configured. Please set your API key in Settings.");
493
+ }
494
+ try {
495
+ const prompt = `You are 'Yuki', a world-renowned bonsai master providing a follow-up consultation for a ${species} in ${location}.
496
+
497
+ Attached is a **new photo** of the bonsai.
498
+
499
+ Below is the **previous analysis JSON data** for context.
500
+
501
+ Your task is to:
502
+ 1. Perform a full, new analysis based on the **new photo**, using the same comprehensive JSON schema as before.
503
+ 2. **Crucially, your analysis should reflect changes from the previous state.** In your text fields (like observations, health assessments), explicitly compare and contrast. For example: "Foliage health has improved, appearing much fuller than in the last analysis," or "A new area of concern is the slight yellowing on the left lower branch, which was not present previously."
504
+ 3. Update the health score and all other fields based on the new visual information. The goal is to create a new, complete report that also serves as a progress update.
505
+
506
+ Previous Analysis for context:
507
+ ${JSON.stringify(previousAnalysis, null, 2)}
508
+ `;
509
+
510
+ const imagePart = {
511
+ inlineData: {
512
+ mimeType: 'image/jpeg',
513
+ data: imageBase64,
514
+ },
515
+ };
516
+
517
+ const textPart = { text: prompt };
518
+
519
+ const response = await ai!.models.generateContent({
520
+ model: 'gemini-2.5-flash',
521
+ contents: { parts: [imagePart, textPart] },
522
+ config: {
523
+ responseMimeType: "application/json",
524
+ responseSchema: bonsaiAnalysisSchema
525
+ }
526
+ });
527
+
528
+ const jsonText = response.text.trim();
529
+ if (!jsonText) {
530
+ console.error("Gemini API returned an empty response for follow-up.");
531
+ return null;
532
+ }
533
+
534
+ return JSON.parse(jsonText) as BonsaiAnalysis;
535
+ } catch (error) {
536
+ return handleAIError(error);
537
+ }
538
+ };
539
+
540
+ export const runHealthCheck = async (
541
+ imageBase64: string,
542
+ species: string,
543
+ location: string,
544
+ problemCategory: string
545
+ ): Promise<HealthCheckResult | null> => {
546
+ if (!isAIConfigured()) {
547
+ throw new Error("AI is not configured. Please set your API key in Settings.");
548
+ }
549
+ try {
550
+ const prompt = `You are 'Yuki', a bonsai diagnostician and plant pathologist. A user needs an urgent health check-up for their bonsai.
551
+
552
+ - **Species:** ${species}
553
+ - **Location:** ${location}
554
+ - **Reported Problem Area:** ${problemCategory}
555
+
556
+ Analyze the provided close-up image showing the problem. Your task is to provide a precise diagnosis and an easy-to-follow treatment plan. Focus ONLY on the visible problem. Be methodical and scientific.
557
+
558
+ 1. **Diagnose:** Identify the specific cause (e.g., 'Spider Mite Infestation', 'Root Rot due to Overwatering', 'Iron Chlorosis'). State your confidence level.
559
+ 2. **Explain:** Describe *why* you made this diagnosis, citing evidence from the image.
560
+ 3. **Treat:** Create a numbered, step-by-step treatment plan. The steps must be clear and actionable for an amateur.
561
+ 4. **Offer Alternatives:** Briefly describe common organic alternatives.
562
+ 5. **Prevent:** Give advice on preventing recurrence.
563
+ 6. **Categorize:** Set the 'isPest' and 'isDisease' flags appropriately.
564
+
565
+ Generate the response strictly following the provided JSON schema.`;
566
+
567
+ const imagePart = { inlineData: { mimeType: 'image/jpeg', data: imageBase64 }};
568
+ const textPart = { text: prompt };
569
+
570
+ const response = await ai!.models.generateContent({
571
+ model: 'gemini-2.5-flash',
572
+ contents: { parts: [imagePart, textPart] },
573
+ config: {
574
+ responseMimeType: "application/json",
575
+ responseSchema: healthCheckResultSchema
576
+ }
577
+ });
578
+
579
+ const jsonText = response.text.trim();
580
+ if (!jsonText) {
581
+ console.error("Gemini API returned an empty response for health check.");
582
+ return null;
583
+ }
584
+
585
+ return JSON.parse(jsonText) as HealthCheckResult;
586
+
587
+ } catch (error) {
588
+ return handleAIError(error);
589
+ }
590
+ };
591
+
592
+ export const identifyBonsaiSpecies = async (imageBase64: string): Promise<SpeciesIdentificationResult | null> => {
593
+ if (!isAIConfigured()) {
594
+ throw new Error("AI is not configured. Please set your API key in Settings.");
595
+ }
596
+ try {
597
+ const prompt = `You are a world-class botanist specializing in bonsai identification. Analyze the provided image of a tree and identify its species. Provide the most likely common name and the scientific name. If you are uncertain, provide a few possibilities with your confidence level for each. For each identification, provide a brief summary of its essential care needs.`;
598
+
599
+ const imagePart = { inlineData: { mimeType: 'image/jpeg', data: imageBase64 }};
600
+ const textPart = { text: prompt };
601
+
602
+ const response = await ai!.models.generateContent({
603
+ model: 'gemini-2.5-flash',
604
+ contents: { parts: [imagePart, textPart] },
605
+ config: {
606
+ responseMimeType: "application/json",
607
+ responseSchema: speciesIdentificationSchema
608
+ }
609
+ });
610
+
611
+ const jsonText = response.text.trim();
612
+ if (!jsonText) {
613
+ console.error("Gemini API returned an empty response for species ID.");
614
+ return null;
615
+ }
616
+
617
+ return JSON.parse(jsonText) as SpeciesIdentificationResult;
618
+
619
+ } catch (error) {
620
+ return handleAIError(error);
621
+ }
622
+ };
623
+
624
+ export const generatePestLibrary = async (location: string): Promise<PestLibraryEntry[] | null> => {
625
+ try {
626
+ const prompt = `You are 'Yuki', a bonsai master and entomologist. Generate a Pest & Disease Library for bonsai cultivators located in/near ${location}. Provide a list of the 5 most common pests and diseases they are likely to encounter. For each entry, provide its name, type (Pest or Disease), a brief description, common symptoms to look for on a bonsai tree, and recommended organic and chemical treatment options.`;
627
+ const result = await generate(prompt, { type: Type.OBJECT, properties: { result: pestLibrarySchema } });
628
+ return result ? result.result : null;
629
+ } catch(error) {
630
+ return handleAIError(error);
631
+ }
632
+ }
633
+
634
+ export const generateSeasonalGuide = async (species: string, location: string): Promise<SeasonalGuide[] | null> => {
635
+ try {
636
+ const prompt = `You are 'Yuki', a world-renowned bonsai master. Create a detailed, year-round seasonal guide for a ${species} bonsai located in the ${location} climate. For each of the four seasons (Spring, Summer, Autumn, Winter), provide a summary of the main cultivation goals and a list of key tasks with their importance level (High, Medium, Low).`;
637
+ const result = await generate(prompt, { type: Type.OBJECT, properties: { result: seasonalGuideSchema } });
638
+ return result ? result.result : null;
639
+ } catch(error) {
640
+ return handleAIError(error);
641
+ }
642
+ }
643
+
644
+ export const generateToolGuide = async (): Promise<ToolRecommendation[] | null> => {
645
+ try {
646
+ const prompt = `You are 'Yuki', a world-renowned bonsai master. Create a comprehensive guide to bonsai tools. Provide a list of essential, recommended, and advanced tools. For each tool, provide its name, category (Cutting, Wiring, Repotting, General Care), a description of its use, and its level (Essential, Recommended, or Advanced).`;
647
+ const result = await generate(prompt, { type: Type.OBJECT, properties: { result: toolRecommendationsSchema } });
648
+ return result ? result.result : null;
649
+ } catch(error) {
650
+ return handleAIError(error);
651
+ }
652
+ }
653
+
654
+ export const generateMaintenanceTips = async (toolName: string): Promise<MaintenanceTips | null> => {
655
+ if (!isAIConfigured()) {
656
+ throw new Error("AI is not configured. Please set your API key in Settings.");
657
+ }
658
+ try {
659
+ const prompt = `You are 'Yuki', a bonsai master and tool maintenance expert. Provide a concise, actionable maintenance guide for a specific bonsai tool: "${toolName}". The guide should be easy for an enthusiast to follow. Cover three key areas: sharpening, cleaning, and storage.`;
660
+ return await generate(prompt, maintenanceTipsSchema);
661
+ } catch(error) {
662
+ return handleAIError(error);
663
+ }
664
+ }
665
+
666
+ export const analyzeDiaryLog = async (
667
+ species: string,
668
+ newPhotosBase64: string[],
669
+ previousPhotoBase64?: string
670
+ ): Promise<DiaryAIAnalysis | null> => {
671
+ if (!isAIConfigured()) {
672
+ throw new Error("AI is not configured. Please set your API key in Settings.");
673
+ }
674
+ try {
675
+ let prompt = `You are 'Yuki', a bonsai master. This is a new diary log for a ${species} bonsai. Analyze the attached new photo(s).`;
676
+ const parts: Part[] = [];
677
+
678
+ if (previousPhotoBase64) {
679
+ prompt += ` A previous photo is also provided for comparison. Note the changes in health, growth, and structure. Provide a concise summary of these changes, estimate the health change as a single positive or negative integer (e.g., +5, -2, 0), and list 1-2 actionable suggestions based on the new visual data.`;
680
+ parts.push({
681
+ inlineData: { mimeType: 'image/jpeg', data: previousPhotoBase64 }
682
+ });
683
+ parts.push({ text: "Previous photo."});
684
+ } else {
685
+ prompt += ` This is the first analyzed entry for this tree. Provide a brief summary of the tree's current state, and list 1-2 actionable suggestions.`;
686
+ }
687
+
688
+ newPhotosBase64.forEach(photo => {
689
+ parts.push({ inlineData: { mimeType: 'image/jpeg', data: photo }});
690
+ });
691
+
692
+ parts.push({ text: prompt });
693
+
694
+ const response = await ai!.models.generateContent({
695
+ model: 'gemini-2.5-flash',
696
+ contents: { parts: parts },
697
+ config: {
698
+ responseMimeType: "application/json",
699
+ responseSchema: diaryAIAnalysisSchema
700
+ }
701
+ });
702
+
703
+ const jsonText = response.text.trim();
704
+ if (!jsonText) {
705
+ console.error("Gemini API returned an empty response.");
706
+ return null;
707
+ }
708
+ return JSON.parse(jsonText) as DiaryAIAnalysis;
709
+
710
+ } catch (error) {
711
+ return handleAIError(error);
712
+ }
713
+ };
714
+
715
+
716
+ export const analyzeGrowthProgression = async (
717
+ species: string,
718
+ photos: { date: string, image: string }[]
719
+ ): Promise<string | null> => {
720
+ if (photos.length < 2) return "Please select at least two photos to see a progression.";
721
+ if (!isAIConfigured()) {
722
+ throw new Error("AI is not configured. Please set your API key in Settings.");
723
+ }
724
+ try {
725
+ const parts: Part[] = [];
726
+ let prompt = `You are 'Yuki', a bonsai master. Analyze the following series of images of a ${species} bonsai, provided with their corresponding dates. The images are ordered chronologically. Write a narrative describing the tree's journey and development over this period. Comment on changes in ramification, trunk thickness, leaf/needle health, styling decisions (like pruning or wiring) that are visible, and the overall aesthetic evolution of the tree. Be insightful and encouraging.
727
+
728
+ `;
729
+
730
+ photos.forEach(({date, image}) => {
731
+ prompt += `Image from: ${new Date(date).toLocaleDateString()}\n`;
732
+ parts.push({ inlineData: { mimeType: 'image/jpeg', data: image }});
733
+ });
734
+
735
+ parts.push({text: prompt});
736
+
737
+ const response = await ai!.models.generateContent({
738
+ model: 'gemini-2.5-flash',
739
+ contents: { parts },
740
+ });
741
+
742
+ return response.text;
743
+
744
+ } catch (error) {
745
+ return handleAIError(error);
746
+ }
747
+ };
748
+
749
+ export const generateStylingBlueprint = async (imageBase64: string, styleInstructions: string): Promise<StylingBlueprint | null> => {
750
+ if (!isAIConfigured()) {
751
+ throw new Error("AI is not configured. Please set your API key in Settings.");
752
+ }
753
+ try {
754
+ const prompt = `You are 'Yuki', a world-renowned bonsai master and digital artist. You will create a digital styling blueprint for a bonsai tree. The user has provided an image and a styling goal: "${styleInstructions}".
755
+
756
+ Your task is to analyze the image, identify key structural elements, and then generate a JSON object representing an SVG overlay to guide the user.
757
+
758
+ **Analysis & Generation Rules:**
759
+ 1. Mentally overlay a 1000x1000 coordinate grid on the provided image. All coordinates in your response MUST be relative to this 1000x1000 canvas.
760
+ 2. Identify the trunk line, major branches, and foliage masses.
761
+ 3. Based on the user's styling goal, create a JSON object conforming to the provided schema.
762
+ 4. **Annotation Guidelines:**
763
+ - **PRUNE_LINE:** Use 'points' to draw a line indicating where a branch should be cut.
764
+ - **REMOVE_BRANCH:** Use 'points' to draw a polygon around a branch to be removed.
765
+ - **WIRE_DIRECTION:** Use an SVG 'path' string (e.g., "M x1 y1 Q x2 y2 x3 y3") to show the new direction of a wired branch.
766
+ - **FOLIAGE_REFINEMENT:** Use 'points' to draw a polygon for the ideal new shape of a foliage pad.
767
+ - **JIN_SHARI:** Use 'points' to outline an area on the trunk/branch to be turned into deadwood.
768
+ - **TRUNK_LINE:** Use a 'path' to highlight the main flow of the trunk.
769
+ - **EXPOSE_ROOT:** Use 'points' to outline an area where soil can be removed to expose more nebari.
770
+ 5. Every annotation MUST have a clear, concise 'label'.
771
+ 6. Provide an overall 'summary' of the styling strategy.
772
+
773
+ Your response MUST be a valid JSON object matching the schema.`;
774
+
775
+ const imagePart = { inlineData: { mimeType: 'image/jpeg', data: imageBase64 } };
776
+ const textPart = { text: prompt };
777
+
778
+ const response = await ai!.models.generateContent({
779
+ model: 'gemini-2.5-flash',
780
+ contents: { parts: [imagePart, textPart] },
781
+ config: {
782
+ responseMimeType: "application/json",
783
+ responseSchema: stylingBlueprintSchema
784
+ }
785
+ });
786
+
787
+ const jsonText = response.text.trim();
788
+ if (!jsonText) {
789
+ console.error("Gemini API returned an empty response for styling blueprint.");
790
+ return null;
791
+ }
792
+
793
+ return JSON.parse(jsonText) as StylingBlueprint;
794
+
795
+ } catch (error) {
796
+ return handleAIError(error);
797
+ }
798
+ };
799
+
800
+
801
+ export const generateBonsaiImage = async (prompt: string): Promise<string | null> => {
802
+ if (!isAIConfigured()) {
803
+ throw new Error("AI is not configured. Please set your API key in Settings.");
804
+ }
805
+ try {
806
+ const response = await ai!.models.generateImages({
807
+ model: 'imagen-3.0-generate-002',
808
+ prompt: prompt,
809
+ config: {
810
+ numberOfImages: 1,
811
+ outputMimeType: 'image/jpeg',
812
+ aspectRatio: '1:1',
813
+ },
814
+ });
815
+
816
+ if (response.generatedImages && response.generatedImages.length > 0) {
817
+ return response.generatedImages[0].image.imageBytes;
818
+ }
819
+ return null;
820
+
821
+ } catch (error) {
822
+ return handleAIError(error);
823
+ }
824
+ };
825
+
826
+ export const analyzeSoilComposition = async (
827
+ imageBase64: string,
828
+ species: string,
829
+ location: string
830
+ ): Promise<SoilAnalysis | null> => {
831
+ if (!isAIConfigured()) {
832
+ throw new Error("AI is not configured. Please set your API key in Settings.");
833
+ }
834
+ try {
835
+ const prompt = `You are a soil scientist specializing in bonsai substrates. Analyze the provided close-up image of a bonsai soil mix. The user states it is for a ${species} bonsai in ${location}. Based on the visual texture, color, and particle shapes:
836
+ 1. Identify the primary components and estimate their percentages. Components can include: Akadama, Pumice, Lava Rock, Organic Compost, Kiryu, Pine Bark, Diatomaceous Earth, Sand, Grit, or Other.
837
+ 2. Assess the overall drainage and water retention properties of the mix.
838
+ 3. Provide a suitability analysis for the specified species and location, considering its water and air requirements.
839
+ 4. Offer actionable suggestions for how this soil mix could be improved for this specific use case.
840
+
841
+ Generate the response strictly following the provided JSON schema.`;
842
+
843
+ const imagePart = { inlineData: { mimeType: 'image/jpeg', data: imageBase64 } };
844
+ const textPart = { text: prompt };
845
+
846
+ const response = await ai!.models.generateContent({
847
+ model: 'gemini-2.5-flash',
848
+ contents: { parts: [imagePart, textPart] },
849
+ config: {
850
+ responseMimeType: "application/json",
851
+ responseSchema: soilAnalysisSchema
852
+ }
853
+ });
854
+
855
+ const jsonText = response.text.trim();
856
+ if (!jsonText) {
857
+ console.error("Gemini API returned an empty response for soil analysis.");
858
+ return null;
859
+ }
860
+ return JSON.parse(jsonText) as SoilAnalysis;
861
+
862
+ } catch (error) {
863
+ return handleAIError(error);
864
+ }
865
+ };
866
+
867
+ export const generateNebariBlueprint = async (imageBase64: string): Promise<StylingBlueprint | null> => {
868
+ if (!isAIConfigured()) {
869
+ throw new Error("AI is not configured. Please set your API key in Settings.");
870
+ }
871
+ try {
872
+ const prompt = `You are a bonsai master specializing in nebari (root flare) development. Analyze the provided image of the base of a bonsai tree. Your goal is to create a visual guide for the user to improve the nebari over several seasons.
873
+ Generate a styling blueprint with annotations showing how to improve the nebari over time. Use annotations like:
874
+ - **PRUNE_LINE**: To indicate a root that should be pruned back to encourage finer roots.
875
+ - **REMOVE_BRANCH**: To mark a large, unsightly, or crossing root for complete removal during the next repotting.
876
+ - **EXPOSE_ROOT**: To outline areas where soil should be carefully removed at the surface to reveal more of the nebari.
877
+ Every annotation must have a clear, concise label explaining the action and its purpose (e.g., 'Prune this thick root to encourage radial growth'). Provide a summary of the overall strategy for nebari development.`;
878
+
879
+ const imagePart = { inlineData: { mimeType: 'image/jpeg', data: imageBase64 } };
880
+ const textPart = { text: prompt };
881
+
882
+ const response = await ai!.models.generateContent({
883
+ model: 'gemini-2.5-flash',
884
+ contents: { parts: [imagePart, textPart] },
885
+ config: {
886
+ responseMimeType: "application/json",
887
+ responseSchema: stylingBlueprintSchema
888
+ }
889
+ });
890
+
891
+ const jsonText = response.text.trim();
892
+ if (!jsonText) {
893
+ console.error("Gemini API returned an empty response for nebari blueprint.");
894
+ return null;
895
+ }
896
+
897
+ return JSON.parse(jsonText) as StylingBlueprint;
898
+
899
+ } catch (error) {
900
+ return handleAIError(error);
901
+ }
902
+ };
903
+
904
+ export const getProtectionProfile = async (species: string): Promise<Omit<ProtectionProfile, 'alertsEnabled'> | null> => {
905
+ if (!isAIConfigured()) {
906
+ throw new Error("AI is not configured. Please set your API key in Settings.");
907
+ }
908
+ try {
909
+ const prompt = `You are a horticultural expert specializing in bonsai. For the bonsai species "${species}", provide its typical environmental tolerance thresholds. Give reasonable, common values for a healthy, established tree.
910
+
911
+ Respond strictly in JSON format matching the schema.`;
912
+ const result = await generate(prompt, protectionProfileSchema);
913
+ return result as Omit<ProtectionProfile, 'alertsEnabled'>;
914
+ } catch (error) {
915
+ return handleAIError(error);
916
+ }
917
+ };
services/mcpService.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ const MCP_SERVER_URL = 'https://black-forest-labs-flux-1-kontext-dev.hf.space/run/FLUX_1_Kontext_Dev_infer';
3
+
4
+ /**
5
+ * Edits a bonsai image using the FLUX.1 Kontext MCP tool.
6
+ * @param imageBase64 The base64 encoded string of the input image.
7
+ * @param prompt The text prompt describing the desired edit.
8
+ * @returns A base64 encoded string of the edited image, or null if an error occurs.
9
+ */
10
+ export const editBonsaiWithKontext = async (
11
+ imageBase64: string,
12
+ prompt: string
13
+ ): Promise<string | null> => {
14
+ try {
15
+ const inputImage = `data:image/jpeg;base64,${imageBase64}`;
16
+
17
+ // Based on the provided documentation and common Gradio API structure.
18
+ // The payload is an object with a 'data' array containing the arguments in order.
19
+ // The order of arguments is assumed from the Python example:
20
+ // input_image, prompt, seed, randomize_seed, guidance_scale
21
+ const payload = {
22
+ data: [
23
+ inputImage, // input_image
24
+ prompt, // prompt
25
+ -1, // seed (-1 for random)
26
+ true, // randomize_seed
27
+ 2.5, // guidance_scale
28
+ ]
29
+ };
30
+
31
+ const response = await fetch(MCP_SERVER_URL, {
32
+ method: 'POST',
33
+ headers: {
34
+ 'Content-Type': 'application/json',
35
+ },
36
+ body: JSON.stringify(payload),
37
+ });
38
+
39
+ if (!response.ok) {
40
+ const errorBody = await response.text();
41
+ console.error("MCP Server Error:", response.status, errorBody);
42
+ throw new Error(`Request failed with status ${response.status}`);
43
+ }
44
+
45
+ const result = await response.json();
46
+
47
+ // Gradio API responses typically wrap the output in a 'data' array.
48
+ // The edited image is expected to be the first element.
49
+ if (result && Array.isArray(result.data) && result.data.length > 0) {
50
+ const outputImage = result.data[0];
51
+ // The output is often a data URI. We need to extract the base64 part.
52
+ if (typeof outputImage === 'string' && outputImage.startsWith('data:image/')) {
53
+ return outputImage.split(',')[1];
54
+ }
55
+ }
56
+
57
+ console.error("Invalid response format from MCP server:", result);
58
+ return null;
59
+
60
+ } catch (error) {
61
+ console.error("Error calling MCP tool:", error);
62
+ return null;
63
+ }
64
+ };
tsconfig.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "experimentalDecorators": true,
5
+ "useDefineForClassFields": false,
6
+ "module": "ESNext",
7
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "isolatedModules": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+ "allowJs": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "noFallthroughCasesInSwitch": true,
24
+ "noUncheckedSideEffectImports": true,
25
+
26
+ "paths": {
27
+ "@/*" : ["./*"]
28
+ }
29
+ }
30
+ }
types.ts ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+ declare const google: {
4
+ accounts: {
5
+ id: {
6
+ initialize: (config: { client_id: string; callback: (response: any) => void; }) => void;
7
+ renderButton: (
8
+ parent: HTMLElement,
9
+ options: { theme?: string; size?: string; type?: string; text?: string, width?: string, logo_alignment?: string }
10
+ ) => void;
11
+ prompt: (notification?: any) => void;
12
+ disableAutoSelect: () => void;
13
+ };
14
+ };
15
+ };
16
+
17
+ declare global {
18
+ interface Window {
19
+ google: typeof google;
20
+ }
21
+ }
22
+
23
+
24
+ export enum AppStatus {
25
+ IDLE = 'IDLE',
26
+ ANALYZING = 'ANALYZING',
27
+ SUCCESS = 'SUCCESS',
28
+ ERROR = 'ERROR',
29
+ }
30
+
31
+ export type View = 'steward' | 'pests' | 'seasons' | 'tools' | 'garden' | 'healthCheck' | 'designStudio' | 'speciesIdentifier' | 'wiringGuide' | 'soilAnalyzer' | 'nebariDeveloper' | 'potCalculator' | 'sunTracker' | 'fertilizerMixer' | 'soilVolumeCalculator' | 'weatherShield' | 'settings';
32
+
33
+ export interface UserProfile {
34
+ id: string;
35
+ name: string;
36
+ email: string;
37
+ picture: string;
38
+ }
39
+
40
+
41
+ export enum LogTag {
42
+ Pruning = 'Pruning',
43
+ Watering = 'Watering',
44
+ Fertilizing = 'Fertilizing',
45
+ Repotting = 'Repotting',
46
+ Wiring = 'Wiring',
47
+ PestControl = 'Pest Control',
48
+ DiseaseControl = 'Disease Control',
49
+ Observation = 'Observation',
50
+ Styling = 'Styling',
51
+ NewGrowth = 'New Growth',
52
+ HealthDiagnosis = 'Health Diagnosis',
53
+ }
54
+
55
+ export interface HealthCheckResult {
56
+ probableCause: string;
57
+ confidence: 'High' | 'Medium' | 'Low';
58
+ explanation: string;
59
+ isPest: boolean;
60
+ isDisease: boolean;
61
+ treatmentPlan: {
62
+ step: number;
63
+ action: string;
64
+ details: string;
65
+ }[];
66
+ organicAlternatives: string;
67
+ preventativeMeasures: string;
68
+ }
69
+
70
+
71
+ export interface DiaryAIAnalysis {
72
+ summary: string;
73
+ healthChange?: number; // e.g., +5, -2, or 0
74
+ suggestions?: string[];
75
+ }
76
+
77
+ export interface DiaryLog {
78
+ id: string; // uuid
79
+ date: string; // ISO string
80
+ title: string;
81
+ notes: string;
82
+ photos: string[]; // base64 strings without the data URI prefix
83
+ tags: LogTag[];
84
+ aiAnalysis?: DiaryAIAnalysis;
85
+ healthCheckResult?: HealthCheckResult;
86
+ }
87
+
88
+ export interface ProtectionProfile {
89
+ minTempC: number;
90
+ maxTempC: number;
91
+ maxWindKph: number;
92
+ alertsEnabled: boolean;
93
+ }
94
+
95
+ export interface BonsaiTree {
96
+ id: string; // uuid
97
+ name: string;
98
+ species: string;
99
+ acquiredDate: string; // ISO string
100
+ source: string; // e.g., Nursery, Collected, Gift
101
+ location: string; // e.g., San Francisco, CA
102
+ initialPhoto: string; // base64 string without the data URI prefix
103
+ notes?: string;
104
+ logs: DiaryLog[];
105
+ analysisHistory: { date: string; analysis: BonsaiAnalysis }[];
106
+ protectionProfile?: ProtectionProfile;
107
+ }
108
+
109
+ export interface CareTask {
110
+ week: number;
111
+ task: string;
112
+ details: string;
113
+ toolsNeeded?: string[];
114
+ }
115
+
116
+ export interface PestAlert {
117
+ pestOrDisease: string;
118
+ symptoms:string;
119
+ treatment: string;
120
+ severity: 'Low' | 'Medium' | 'High';
121
+ }
122
+
123
+ export interface StylingSuggestion {
124
+ technique: 'Pruning' | 'Wiring' | 'Shaping';
125
+ description: string;
126
+ area: string;
127
+ }
128
+
129
+ export interface EnvironmentalFactors {
130
+ idealLight: string;
131
+ idealHumidity: string;
132
+ temperatureRange: string;
133
+ }
134
+
135
+ export interface WateringAnalysis {
136
+ frequency: string;
137
+ method: string;
138
+ notes: string;
139
+ }
140
+
141
+ export interface FertilizerRec {
142
+ phase: 'Spring Growth' | 'Summer Maintenance' | 'Autumn Preparation' | 'Winter Dormancy';
143
+ type: string;
144
+ frequency: string;
145
+ notes: string;
146
+ }
147
+
148
+ export interface SoilRecipeComponent {
149
+ name: 'Akadama' | 'Pumice' | 'Lava Rock' | 'Organic Compost' | 'Kiryu' | 'Other';
150
+ percentage: number;
151
+ notes: string;
152
+ }
153
+
154
+ export interface SoilRecipe {
155
+ components: SoilRecipeComponent[];
156
+ rationale: string;
157
+ }
158
+
159
+ export interface PotSuggestion {
160
+ style: string;
161
+ size: string;
162
+ colorPalette: string;
163
+ rationale:string;
164
+ }
165
+
166
+ export interface SeasonalTask {
167
+ task: string;
168
+ importance: 'High' | 'Medium' | 'Low';
169
+ }
170
+
171
+ export interface SeasonalGuide {
172
+ season: 'Spring' | 'Summer' | 'Autumn' | 'Winter';
173
+ summary: string;
174
+ tasks: SeasonalTask[];
175
+ }
176
+
177
+ export interface DiagnosticIssue {
178
+ issue: string;
179
+ confidence: 'High' | 'Medium' | 'Low';
180
+ symptoms: string;
181
+ solution: string;
182
+ }
183
+
184
+ export interface PestLibraryEntry {
185
+ name: string;
186
+ type: 'Pest' | 'Disease';
187
+ description: string;
188
+ symptoms: string[];
189
+ treatment: {
190
+ organic: string;
191
+ chemical: string;
192
+ };
193
+ }
194
+
195
+ export interface ToolRecommendation {
196
+ name: string;
197
+ category: 'Cutting' | 'Wiring' | 'Repotting' | 'General Care';
198
+ description: string;
199
+ level: 'Essential' | 'Recommended' | 'Advanced';
200
+ }
201
+
202
+ export enum ToolCondition {
203
+ EXCELLENT = 'Excellent',
204
+ GOOD = 'Good',
205
+ NEEDS_SHARPENING = 'Needs Sharpening',
206
+ NEEDS_OILING = 'Needs Oiling',
207
+ DAMAGED = 'Damaged'
208
+ }
209
+
210
+ export interface UserTool extends ToolRecommendation {
211
+ id: string; // Unique ID for the user's instance of the tool
212
+ condition: ToolCondition;
213
+ lastMaintained?: string; // ISO date string
214
+ notes?: string;
215
+ }
216
+
217
+ export interface MaintenanceTips {
218
+ sharpening: string;
219
+ cleaning: string;
220
+ storage: string;
221
+ }
222
+
223
+ export interface BonsaiAnalysis {
224
+ species: string;
225
+ healthAssessment: {
226
+ overallHealth: string;
227
+ healthScore: number;
228
+ observations: string[];
229
+ foliageHealth: string;
230
+ trunkAndNebariHealth: string;
231
+ potAndSoilHealth: string;
232
+ };
233
+ careSchedule: CareTask[];
234
+ pestAndDiseaseAlerts: PestAlert[];
235
+ stylingSuggestions: StylingSuggestion[];
236
+ environmentalFactors: EnvironmentalFactors;
237
+ wateringAnalysis: WateringAnalysis;
238
+ knowledgeNuggets: string[];
239
+ estimatedAge: string;
240
+ fertilizerRecommendations: FertilizerRec[];
241
+ soilRecipe: SoilRecipe;
242
+ potSuggestion: PotSuggestion;
243
+ seasonalGuide: SeasonalGuide[];
244
+ diagnostics: DiagnosticIssue[];
245
+ pestLibrary: PestLibraryEntry[];
246
+ toolRecommendations: ToolRecommendation[];
247
+ }
248
+
249
+ export interface SpeciesIdentification {
250
+ commonName: string;
251
+ scientificName: string;
252
+ confidence: string;
253
+ reasoning: string;
254
+ generalCareSummary: string;
255
+ }
256
+
257
+ export interface SpeciesIdentificationResult {
258
+ identifications: SpeciesIdentification[];
259
+ }
260
+
261
+ export interface SoilAnalysisComponent {
262
+ name: 'Akadama' | 'Pumice' | 'Lava Rock' | 'Organic Compost' | 'Kiryu' | 'Pine Bark' | 'Diatomaceous Earth' | 'Sand' | 'Grit' | 'Other';
263
+ percentage: number;
264
+ }
265
+
266
+ export interface SoilAnalysis {
267
+ components: SoilAnalysisComponent[];
268
+ drainageRating: 'Poor' | 'Average' | 'Good' | 'Excellent';
269
+ waterRetention: 'Low' | 'Medium' | 'High';
270
+ suitabilityAnalysis: string;
271
+ improvementSuggestions: string;
272
+ }
273
+
274
+
275
+ // --- AI Design Studio Blueprint Types ---
276
+
277
+ export interface SvgPoint {
278
+ x: number;
279
+ y: number;
280
+ }
281
+
282
+ export enum AnnotationType {
283
+ PruneLine = 'PRUNE_LINE',
284
+ WireDirection = 'WIRE_DIRECTION',
285
+ RemoveBranch = 'REMOVE_BRANCH',
286
+ FoliageRefinement = 'FOLIAGE_REFINEMENT',
287
+ JinShari = 'JIN_SHARI',
288
+ TrunkLine = 'TRUNK_LINE',
289
+ ExposeRoot = 'EXPOSE_ROOT',
290
+ }
291
+
292
+ export interface StylingAnnotation {
293
+ type: AnnotationType;
294
+ points?: SvgPoint[]; // For lines and polygons
295
+ path?: string; // For complex curves like wiring direction
296
+ label: string;
297
+ }
298
+
299
+ export interface StylingBlueprint {
300
+ annotations: StylingAnnotation[];
301
+ summary: string;
302
+ // AI provides coordinates based on a 1000x1000 canvas.
303
+ // The frontend will scale these to the actual image dimensions.
304
+ canvas: {
305
+ width: number;
306
+ height: number;
307
+ };
308
+ }
views/AiStewardView.tsx ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+
4
+
5
+
6
+
7
+ import React, { useState, useCallback, useEffect } from 'react';
8
+ import ImageUploader from '../components/ImageUploader';
9
+ import AnalysisDisplay from '../components/AnalysisDisplay';
10
+ import Spinner from '../components/Spinner';
11
+ import { SparklesIcon, AlertTriangleIcon } from '../components/icons';
12
+ import { analyzeBonsai, getProtectionProfile, isAIConfigured } from '../services/geminiService';
13
+ import type { BonsaiAnalysis, BonsaiTree, View } from '../types';
14
+ import { AppStatus } from '../types';
15
+
16
+ interface AiStewardViewProps {
17
+ setActiveView: (view: View) => void;
18
+ }
19
+
20
+ const AiStewardView: React.FC<AiStewardViewProps> = ({ setActiveView }) => {
21
+ const [status, setStatus] = useState<AppStatus>(AppStatus.IDLE);
22
+ const [analysisResult, setAnalysisResult] = useState<BonsaiAnalysis | null>(null);
23
+ const [error, setError] = useState<string>('');
24
+ const [currentImage, setCurrentImage] = useState<string>('');
25
+ const [currentLocation, setCurrentLocation] = useState('');
26
+ const [prefilledSpecies, setPrefilledSpecies] = useState('');
27
+ const aiConfigured = isAIConfigured();
28
+
29
+ useEffect(() => {
30
+ const species = window.sessionStorage.getItem('prefilled-species');
31
+ if (species) {
32
+ setPrefilledSpecies(species);
33
+ window.sessionStorage.removeItem('prefilled-species');
34
+ }
35
+ }, []);
36
+
37
+ const handleAnalyze = useCallback(async (imageBase64: string, species: string, location: string) => {
38
+ setStatus(AppStatus.ANALYZING);
39
+ setError('');
40
+ setAnalysisResult(null);
41
+ setCurrentImage(imageBase64);
42
+ setCurrentLocation(location);
43
+
44
+ try {
45
+ const result = await analyzeBonsai(imageBase64, species, location);
46
+ if (result) {
47
+ setAnalysisResult(result);
48
+ setStatus(AppStatus.SUCCESS);
49
+ } else {
50
+ throw new Error('Failed to get analysis. The AI may be busy, or there was an issue with the request. Please try again.');
51
+ }
52
+ } catch (e: any) {
53
+ setError(e.message);
54
+ setStatus(AppStatus.ERROR);
55
+ }
56
+ }, []);
57
+
58
+ const handleSaveToDiary = async () => {
59
+ try {
60
+ if (!analysisResult || !currentImage || !currentLocation) {
61
+ alert("Cannot save. Analysis data is missing.");
62
+ return;
63
+ }
64
+
65
+ const treeName = window.prompt("What would you like to name this tree in your garden?", analysisResult.species);
66
+ if (!treeName) return; // User cancelled
67
+
68
+ // Fetch the protection profile for the new tree
69
+ const protectionProfileData = await getProtectionProfile(analysisResult.species);
70
+
71
+ const newTree: BonsaiTree = {
72
+ id: `tree-${Date.now()}`,
73
+ name: treeName,
74
+ species: analysisResult.species,
75
+ acquiredDate: new Date().toISOString(),
76
+ source: "AI Steward Analysis",
77
+ location: currentLocation,
78
+ initialPhoto: currentImage,
79
+ logs: [],
80
+ analysisHistory: [{ date: new Date().toISOString(), analysis: analysisResult }],
81
+ protectionProfile: protectionProfileData ? { ...protectionProfileData, alertsEnabled: true } : undefined,
82
+ };
83
+
84
+ const storageKey = `yuki-app-bonsai-diary-trees`;
85
+ const existingTreesJSON = window.localStorage.getItem(storageKey);
86
+ const existingTrees: BonsaiTree[] = existingTreesJSON ? JSON.parse(existingTreesJSON) : [];
87
+ const updatedTrees = [...existingTrees, newTree];
88
+ window.localStorage.setItem(storageKey, JSON.stringify(updatedTrees));
89
+ window.localStorage.setItem('yuki-bonsai-diary-newly-added-tree-id', newTree.id);
90
+ setActiveView('garden');
91
+
92
+ } catch (error) {
93
+ console.error("Failed to save tree to garden:", error);
94
+ alert("Could not save the tree to your garden. An unexpected error occurred. Please check browser permissions for storage and try again.");
95
+ }
96
+ };
97
+
98
+
99
+ const handleReset = () => {
100
+ setStatus(AppStatus.IDLE);
101
+ setAnalysisResult(null);
102
+ setError('');
103
+ setCurrentImage('');
104
+ setCurrentLocation('');
105
+ setPrefilledSpecies('');
106
+ };
107
+
108
+ const renderContent = () => {
109
+ switch (status) {
110
+ case AppStatus.ANALYZING:
111
+ return <div className="flex justify-center items-center h-full"><Spinner /></div>;
112
+ case AppStatus.SUCCESS:
113
+ return analysisResult ? <AnalysisDisplay analysis={analysisResult} onReset={handleReset} onSaveToDiary={handleSaveToDiary} treeImageBase64={currentImage} /> : null;
114
+ case AppStatus.ERROR:
115
+ return (
116
+ <div className="text-center p-8 bg-white rounded-lg shadow-lg border border-red-200 max-w-md mx-auto">
117
+ <h3 className="text-xl font-semibold text-red-700">An Error Occurred</h3>
118
+ <p className="text-stone-600 mt-2">{error}</p>
119
+ <button
120
+ onClick={handleReset}
121
+ className="mt-6 bg-green-700 text-white font-semibold py-2 px-6 rounded-lg hover:bg-green-600 transition-colors"
122
+ >
123
+ Try Again
124
+ </button>
125
+ </div>
126
+ );
127
+ case AppStatus.IDLE:
128
+ default:
129
+ return (
130
+ <>
131
+ <ImageUploader onAnalyze={handleAnalyze} isAnalyzing={false} defaultSpecies={prefilledSpecies} disabled={!aiConfigured}/>
132
+ {!aiConfigured && (
133
+ <div className="mt-6 p-4 bg-yellow-50 text-yellow-800 rounded-lg border border-yellow-200 text-center max-w-2xl mx-auto">
134
+ <div className="flex items-center justify-center gap-2">
135
+ <AlertTriangleIcon className="w-5 h-5"/>
136
+ <h3 className="font-semibold">AI Features Disabled</h3>
137
+ </div>
138
+ <p className="text-sm mt-1">
139
+ Please set your Gemini API key in the{' '}
140
+ <button onClick={() => setActiveView('settings')} className="font-bold underline hover:text-yellow-900">
141
+ Settings page
142
+ </button>
143
+ {' '}to enable this feature.
144
+ </p>
145
+ </div>
146
+ )}
147
+ </>
148
+ );
149
+ }
150
+ };
151
+
152
+ return (
153
+ <div className="space-y-8">
154
+ <header className="text-center">
155
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
156
+ <SparklesIcon className="w-8 h-8 text-green-600" />
157
+ New Tree Analysis
158
+ </h2>
159
+ <p className="mt-4 text-lg leading-8 text-stone-600 max-w-2xl mx-auto">
160
+ Welcome a new tree to your collection. Get an instant, expert analysis from Yuki, our AI Bonsai Sensei.
161
+ </p>
162
+ </header>
163
+ <div className="w-full">
164
+ {renderContent()}
165
+ </div>
166
+ </div>
167
+ );
168
+ };
169
+
170
+ export default AiStewardView;
views/BonsaiDiaryView.tsx ADDED
@@ -0,0 +1,753 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+
4
+
5
+
6
+
7
+ import React, { useState, useCallback, useRef, useEffect } from 'react';
8
+ import { BookUserIcon, PlusCircleIcon, ArrowLeftIcon, HistoryIcon, SparklesIcon, GalleryHorizontalIcon, ImagePlusIcon, Trash2Icon, BugIcon, WrenchIcon, BonsaiIcon, CameraIcon, CalendarIcon, SunIcon, WindIcon, SnowflakeIcon, SunriseIcon, LeafIcon, UmbrellaIcon, AlertTriangleIcon } from '../components/icons';
9
+ import type { View, BonsaiTree, DiaryLog, BonsaiAnalysis, SeasonalGuide, ProtectionProfile } from '../types';
10
+ import { LogTag } from '../types';
11
+ import { analyzeDiaryLog, analyzeGrowthProgression, analyzeFollowUp, generateSeasonalGuide, getProtectionProfile, isAIConfigured } from '../services/geminiService';
12
+ import Spinner from '../components/Spinner';
13
+ import { useLocalStorage } from '../hooks/useLocalStorage';
14
+ import AnalysisDisplay from '../components/AnalysisDisplay';
15
+
16
+ // --- Main Component ---
17
+ const BonsaiDiaryView: React.FC<{ setActiveView: (view: View) => void }> = ({ setActiveView }) => {
18
+ const [trees, setTrees] = useLocalStorage<BonsaiTree[]>('bonsai-diary-trees', []);
19
+ const [selectedTreeId, setSelectedTreeId] = useState<string | null>(null);
20
+ const [isAddTreeModalOpen, setAddTreeModalOpen] = useState(false);
21
+ const [newlyAddedId, setNewlyAddedId] = useState<string|null>(null);
22
+ const aiConfigured = isAIConfigured();
23
+
24
+ useEffect(() => {
25
+ const newId = window.localStorage.getItem('yuki-bonsai-diary-newly-added-tree-id');
26
+ if (newId) {
27
+ setNewlyAddedId(newId);
28
+ setSelectedTreeId(newId);
29
+ window.localStorage.removeItem('yuki-bonsai-diary-newly-added-tree-id');
30
+ }
31
+ }, []);
32
+
33
+ const selectedTree = trees.find(t => t.id === selectedTreeId);
34
+
35
+ const handleAddTree = async (newTreeData: Omit<BonsaiTree, 'id' | 'logs' | 'analysisHistory'>) => {
36
+ const newTree: BonsaiTree = {
37
+ ...newTreeData,
38
+ id: `tree-${Date.now()}`,
39
+ logs: [],
40
+ analysisHistory: [],
41
+ };
42
+
43
+ setTrees(prev => [...prev, newTree]);
44
+
45
+ // Asynchronously fetch and update the protection profile
46
+ if (aiConfigured) {
47
+ const profile = await getProtectionProfile(newTree.species);
48
+ if(profile) {
49
+ const treeWithProfile = { ...newTree, protectionProfile: { ...profile, alertsEnabled: true }};
50
+ setTrees(prev => prev.map(t => t.id === newTree.id ? treeWithProfile : t));
51
+ }
52
+ }
53
+ };
54
+
55
+ const handleUpdateTree = (updatedTree: BonsaiTree) => {
56
+ setTrees(prev => prev.map(t => t.id === updatedTree.id ? updatedTree : t));
57
+ };
58
+
59
+ const handleDeleteTree = (treeId: string) => {
60
+ if (window.confirm("Are you sure you want to delete this tree and all its logs? This cannot be undone.")) {
61
+ setTrees(prev => prev.filter(t => t.id !== treeId));
62
+ setSelectedTreeId(null);
63
+ }
64
+ };
65
+
66
+ return (
67
+ <div className="space-y-8 max-w-6xl mx-auto">
68
+ <header className="text-center">
69
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
70
+ <BookUserIcon className="w-8 h-8 text-blue-600" />
71
+ My Garden
72
+ </h2>
73
+ <p className="mt-4 text-lg leading-8 text-stone-600">
74
+ Chronicle the life and growth of your personal trees.
75
+ </p>
76
+ </header>
77
+
78
+ {selectedTree ? (
79
+ <TreeDetail
80
+ tree={selectedTree}
81
+ onBack={() => {
82
+ setSelectedTreeId(null);
83
+ if (newlyAddedId) {
84
+ setNewlyAddedId(null);
85
+ }
86
+ }}
87
+ onUpdate={handleUpdateTree}
88
+ onDelete={handleDeleteTree}
89
+ setActiveView={setActiveView}
90
+ showInitialReportOnLoad={newlyAddedId === selectedTree.id}
91
+ isAIConfigured={aiConfigured}
92
+ />
93
+ ) : (
94
+ <>
95
+ <div className="flex justify-end">
96
+ <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">
97
+ <PlusCircleIcon className="w-5 h-5" />
98
+ Add New Tree
99
+ </button>
100
+ </div>
101
+ <TreeList trees={trees} onSelectTree={setSelectedTreeId} />
102
+ </>
103
+ )}
104
+
105
+ {isAddTreeModalOpen && (
106
+ <Modal onClose={() => setAddTreeModalOpen(false)} title="Add a New Bonsai to Your Garden">
107
+ <AddTreeForm
108
+ onAddTree={handleAddTree}
109
+ onClose={() => setAddTreeModalOpen(false)}
110
+ />
111
+ </Modal>
112
+ )}
113
+ </div>
114
+ );
115
+ };
116
+
117
+ // --- Sub-components ---
118
+
119
+ const TreeList: React.FC<{trees: BonsaiTree[], onSelectTree: (id: string) => void}> = ({ trees, onSelectTree }) => {
120
+ if (trees.length === 0) {
121
+ return (
122
+ <div className="text-center bg-white rounded-xl shadow-md border border-stone-200 p-12">
123
+ <BonsaiIcon className="mx-auto h-16 w-16 text-stone-400" />
124
+ <h3 className="mt-4 text-xl font-semibold text-stone-800">Your Garden is Empty</h3>
125
+ <p className="mt-2 text-stone-600">Click "Add New Tree" or use the "New Tree Analysis" to get started.</p>
126
+ </div>
127
+ );
128
+ }
129
+ return (
130
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
131
+ {trees.map(tree => (
132
+ <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)}>
133
+ <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" />
134
+ <div className="p-4 flex-grow flex flex-col">
135
+ <h3 className="text-lg font-bold text-stone-900">{tree.name}</h3>
136
+ <p className="text-sm text-stone-600">{tree.species}</p>
137
+ <div className="mt-auto pt-4 text-xs text-stone-500">
138
+ <p>Acquired: {new Date(tree.acquiredDate).toLocaleDateString()}</p>
139
+ <p>{tree.logs.length} log entries. {tree.analysisHistory.length} analysis reports.</p>
140
+ </div>
141
+ </div>
142
+ </div>
143
+ ))}
144
+ </div>
145
+ );
146
+ };
147
+
148
+ 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 }) => {
149
+ const [isAddLogModalOpen, setAddLogModalOpen] = useState(false);
150
+ const [isGrowthModalOpen, setGrowthModalOpen] = useState(false);
151
+ const [activeReport, setActiveReport] = useState<BonsaiAnalysis | null>(null);
152
+ const [isAnalyzingFollowUp, setIsAnalyzingFollowUp] = useState(false);
153
+ const followUpPhotoInputRef = useRef<HTMLInputElement>(null);
154
+ const [followUpError, setFollowUpError] = useState('');
155
+
156
+ const [isSeasonalPlanOpen, setSeasonalPlanOpen] = useState(false);
157
+
158
+ useEffect(() => {
159
+ if (showInitialReportOnLoad && tree.analysisHistory.length > 0) {
160
+ setActiveReport(tree.analysisHistory[0].analysis);
161
+ }
162
+ }, [showInitialReportOnLoad, tree.analysisHistory]);
163
+
164
+ const handleAddLog = async (newLogData: Omit<DiaryLog, 'id'>, analyze: boolean) => {
165
+ let newLog: DiaryLog = { ...newLogData, id: `log-${Date.now()}`};
166
+
167
+ if (analyze && newLog.photos.length > 0 && isAIConfigured) {
168
+ try {
169
+ const previousLogWithPhoto = [...tree.logs].reverse().find(l => l.photos.length > 0);
170
+ const analysis = await analyzeDiaryLog(tree.species, newLog.photos, previousLogWithPhoto?.photos[0]);
171
+ if (analysis) {
172
+ newLog.aiAnalysis = analysis;
173
+ }
174
+ } catch (e: any) {
175
+ alert(`Could not get AI analysis: ${e.message}`);
176
+ }
177
+ }
178
+
179
+ const updatedTree = { ...tree, logs: [...tree.logs, newLog].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) };
180
+ onUpdate(updatedTree);
181
+ };
182
+
183
+ const handleRequestFollowUp = () => {
184
+ setFollowUpError('');
185
+ followUpPhotoInputRef.current?.click();
186
+ };
187
+
188
+ const handleFollowUpPhotoSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
189
+ const file = event.target.files?.[0];
190
+ if (!file) return;
191
+
192
+ setIsAnalyzingFollowUp(true);
193
+
194
+ const reader = new FileReader();
195
+ reader.onloadend = async () => {
196
+ const imageBase64 = (reader.result as string).split(',')[1];
197
+ const latestAnalysis = tree.analysisHistory[0]?.analysis;
198
+
199
+ if (!latestAnalysis) {
200
+ setFollowUpError("No initial analysis found. Please use the AI Steward first.");
201
+ setIsAnalyzingFollowUp(false);
202
+ return;
203
+ }
204
+ try {
205
+ const result = await analyzeFollowUp(imageBase64, latestAnalysis, tree.species, tree.location);
206
+
207
+ if (result) {
208
+ const newAnalysisEntry = { date: new Date().toISOString(), analysis: result };
209
+ const updatedTree = { ...tree, analysisHistory: [newAnalysisEntry, ...tree.analysisHistory] };
210
+ onUpdate(updatedTree);
211
+ setActiveReport(result);
212
+ } else {
213
+ throw new Error("Failed to get follow-up analysis. Please try again.");
214
+ }
215
+ } catch (e: any) {
216
+ setFollowUpError(e.message);
217
+ } finally {
218
+ setIsAnalyzingFollowUp(false);
219
+ }
220
+ };
221
+ reader.readAsDataURL(file);
222
+ event.target.value = '';
223
+ };
224
+
225
+
226
+ return (
227
+ <div className="bg-white rounded-xl shadow-lg border border-stone-200 p-6 space-y-6">
228
+ <input type="file" ref={followUpPhotoInputRef} onChange={handleFollowUpPhotoSelected} accept="image/*" className="sr-only"/>
229
+ <div className="flex justify-between items-start">
230
+ <div>
231
+ <button onClick={onBack} className="flex items-center gap-2 text-sm font-semibold text-green-700 hover:text-green-600 mb-2">
232
+ <ArrowLeftIcon className="w-5 h-5"/> Back to All Trees
233
+ </button>
234
+ <h3 className="text-3xl font-bold text-stone-900">{tree.name}</h3>
235
+ <p className="text-md text-stone-600">{tree.species}</p>
236
+ </div>
237
+ <div className="flex flex-wrap gap-2 justify-end">
238
+ <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">
239
+ {isAnalyzingFollowUp ? <Spinner text='' /> : <><CameraIcon className="w-4 h-4" /> Request Follow-up</>}
240
+ </button>
241
+ <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">
242
+ <CalendarIcon className="w-4 h-4"/> View Seasonal Plan
243
+ </button>
244
+ <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}>
245
+ <GalleryHorizontalIcon className="w-4 h-4" /> AI Growth Visualizer
246
+ </button>
247
+ <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">
248
+ <PlusCircleIcon className="w-4 h-4" /> New Log Entry
249
+ </button>
250
+ <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">
251
+ <Trash2Icon className="w-4 h-4" />
252
+ </button>
253
+ </div>
254
+ </div>
255
+
256
+ {!isAIConfigured && (
257
+ <div className="p-3 bg-yellow-50 text-yellow-800 rounded-lg border border-yellow-200 text-center text-sm">
258
+ Some AI features like Follow-up Analysis and Seasonal Plans are disabled. Please set your Gemini API key in the{' '}
259
+ <button onClick={() => setActiveView('settings')} className="font-bold underline hover:text-yellow-900">
260
+ Settings page
261
+ </button>.
262
+ </div>
263
+ )}
264
+ {followUpError && <p className="text-sm text-red-600 p-3 bg-red-50 rounded-lg text-center">{followUpError}</p>}
265
+
266
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
267
+ <div className="space-y-6">
268
+ {tree.analysisHistory.length > 0 && (
269
+ <div className="bg-stone-50 p-4 rounded-lg border border-stone-200">
270
+ <h4 className="text-lg font-semibold text-stone-800 mb-2">Analysis History</h4>
271
+ <div className="flex flex-wrap gap-2">
272
+ {tree.analysisHistory.map((entry, index) => (
273
+ <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">
274
+ Report from {new Date(entry.date).toLocaleDateString()}
275
+ </button>
276
+ ))}
277
+ </div>
278
+ </div>
279
+ )}
280
+ <ProtectionSettings tree={tree} onUpdate={onUpdate} isAIConfigured={isAIConfigured} />
281
+ </div>
282
+ {/* Timeline */}
283
+ <div className="relative pl-8 border-l-2 border-stone-100">
284
+ <div className="absolute left-0 top-0 -translate-x-1/2 w-0.5 h-full bg-stone-200"></div>
285
+ {tree.logs.length > 0 ? tree.logs.map(log => <LogEntry key={log.id} log={log} setActiveView={setActiveView} />) : (
286
+ <div className="text-center py-10">
287
+ <HistoryIcon className="mx-auto h-12 w-12 text-stone-400" />
288
+ <p className="mt-2 text-stone-600">No log entries yet. Add one to start the timeline!</p>
289
+ </div>
290
+ )}
291
+ </div>
292
+ </div>
293
+
294
+ {isAddLogModalOpen && (
295
+ <Modal onClose={() => setAddLogModalOpen(false)} title={`New Log for ${tree.name}`}>
296
+ <AddLogForm onAddLog={handleAddLog} onClose={() => setAddLogModalOpen(false)} isAIConfigured={isAIConfigured}/>
297
+ </Modal>
298
+ )}
299
+ {isGrowthModalOpen && (
300
+ <Modal onClose={() => setGrowthModalOpen(false)} title="AI Growth Visualizer">
301
+ <GrowthVisualizer tree={tree} onClose={() => setGrowthModalOpen(false)} />
302
+ </Modal>
303
+ )}
304
+ {activeReport && (
305
+ <Modal onClose={() => setActiveReport(null)} title={`Analysis for ${tree.name}`}>
306
+ <div className="max-h-[80vh] overflow-y-auto -m-6 p-1">
307
+ <div className="p-6">
308
+ <AnalysisDisplay analysis={activeReport} isReadonly={true} treeImageBase64={tree.initialPhoto} />
309
+ </div>
310
+ </div>
311
+ </Modal>
312
+ )}
313
+ {isSeasonalPlanOpen && (
314
+ <Modal onClose={() => setSeasonalPlanOpen(false)} title={`Seasonal Plan for ${tree.name}`}>
315
+ <SeasonalPlanModal tree={tree} onClose={() => setSeasonalPlanOpen(false)} isAIConfigured={isAIConfigured} />
316
+ </Modal>
317
+ )}
318
+ </div>
319
+ )
320
+ };
321
+
322
+ const ProtectionSettings: React.FC<{tree: BonsaiTree, onUpdate: (tree: BonsaiTree) => void, isAIConfigured: boolean}> = ({ tree, onUpdate, isAIConfigured }) => {
323
+
324
+ const handleProfileChange = (field: keyof ProtectionProfile, value: any) => {
325
+ const updatedProfile = {
326
+ ...tree.protectionProfile,
327
+ minTempC: tree.protectionProfile?.minTempC ?? 0,
328
+ maxTempC: tree.protectionProfile?.maxTempC ?? 40,
329
+ maxWindKph: tree.protectionProfile?.maxWindKph ?? 50,
330
+ alertsEnabled: tree.protectionProfile?.alertsEnabled ?? true,
331
+ [field]: value,
332
+ };
333
+ onUpdate({ ...tree, protectionProfile: updatedProfile });
334
+ };
335
+
336
+ if (!tree.protectionProfile) {
337
+ return (
338
+ <div className="bg-stone-50 p-4 rounded-lg border border-stone-200 text-center">
339
+ {isAIConfigured ? <Spinner text="Fetching protection profile..." /> : <p className="text-sm text-stone-500">Protection profile generation disabled. Set API key in Settings.</p>}
340
+ </div>
341
+ );
342
+ }
343
+
344
+ return (
345
+ <div className="bg-stone-50 p-4 rounded-lg border border-stone-200">
346
+ <div className="flex justify-between items-center mb-3">
347
+ <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>
348
+ <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" />
349
+ </div>
350
+ <div className="space-y-3 text-sm">
351
+ <div className="flex items-center justify-between">
352
+ <label htmlFor="minTemp">Min Temp (°C)</label>
353
+ <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" />
354
+ </div>
355
+ <div className="flex items-center justify-between">
356
+ <label htmlFor="maxTemp">Max Temp (°C)</label>
357
+ <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" />
358
+ </div>
359
+ <div className="flex items-center justify-between">
360
+ <label htmlFor="maxWind">Max Wind (km/h)</label>
361
+ <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" />
362
+ </div>
363
+ </div>
364
+ </div>
365
+ );
366
+ };
367
+
368
+
369
+ const LogEntry: React.FC<{log: DiaryLog, setActiveView: (view: View) => void}> = ({log, setActiveView}) => {
370
+ const tagLinks: Partial<Record<LogTag, {icon: React.FC<any>, view: View}>> = {
371
+ [LogTag.PestControl]: { icon: BugIcon, view: 'pests' },
372
+ [LogTag.DiseaseControl]: { icon: BugIcon, view: 'pests' },
373
+ [LogTag.Wiring]: { icon: WrenchIcon, view: 'tools' },
374
+ [LogTag.Pruning]: { icon: WrenchIcon, view: 'tools' },
375
+ [LogTag.Repotting]: { icon: WrenchIcon, view: 'tools' },
376
+ };
377
+
378
+ return (
379
+ <div className="relative mb-8 pl-4">
380
+ <div className="absolute -left-[9px] top-1.5 w-5 h-5 bg-green-600 rounded-full border-4 border-white"></div>
381
+ <p className="text-sm font-semibold text-stone-500">{new Date(log.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
382
+ <h4 className="text-lg font-bold text-stone-800">{log.title}</h4>
383
+
384
+ {log.aiAnalysis && (
385
+ <div className="my-3 p-3 bg-green-50 border-l-4 border-green-500 rounded-r-lg">
386
+ <div className="flex items-center gap-2 text-green-800 font-bold">
387
+ <SparklesIcon className="w-5 h-5" />
388
+ <span>Yuki's Analysis</span>
389
+ </div>
390
+ <p className="text-sm text-green-700 mt-1">{log.aiAnalysis.summary}</p>
391
+ {log.aiAnalysis.suggestions && log.aiAnalysis.suggestions.map((s, i) => (
392
+ <p key={i} className="text-sm text-green-700 mt-1 italic">Suggestion: {s}</p>
393
+ ))}
394
+ </div>
395
+ )}
396
+
397
+ {log.healthCheckResult && (
398
+ <div className="my-3 p-4 bg-amber-50 border-l-4 border-amber-500 rounded-r-lg">
399
+ <div className="flex items-center gap-2 text-amber-800 font-bold">
400
+ <SparklesIcon className="w-5 h-5" />
401
+ <span>Health Check Diagnosis</span>
402
+ </div>
403
+ <p className="text-sm text-amber-900 mt-1 font-semibold">Probable Cause: {log.healthCheckResult.probableCause} ({log.healthCheckResult.confidence} Confidence)</p>
404
+ <p className="text-sm text-amber-800 mt-1">{log.healthCheckResult.explanation}</p>
405
+ </div>
406
+ )}
407
+
408
+ <p className="text-stone-600 mt-2 whitespace-pre-wrap">{log.notes}</p>
409
+
410
+ {log.photos.length > 0 && (
411
+ <div className="mt-3 grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
412
+ {log.photos.map((photo, i) => (
413
+ <img key={i} src={`data:image/jpeg;base64,${photo}`} alt={`Log entry ${i+1}`} className="rounded-md object-cover aspect-square" />
414
+ ))}
415
+ </div>
416
+ )}
417
+
418
+ <div className="mt-3 flex flex-wrap items-center gap-2">
419
+ {log.tags.map(tag => {
420
+ const link = tagLinks[tag];
421
+ return (
422
+ <div key={tag} className="flex items-center bg-stone-100 text-stone-700 text-xs font-medium px-2.5 py-1 rounded-full">
423
+ <span>{tag}</span>
424
+ {link && (
425
+ <button onClick={() => setActiveView(link.view)} className="ml-1.5 p-0.5 rounded-full hover:bg-stone-300" title={`Go to ${link.view}`}>
426
+ <link.icon className="w-3 h-3"/>
427
+ </button>
428
+ )}
429
+ </div>
430
+ )
431
+ })}
432
+ </div>
433
+ </div>
434
+ );
435
+ }
436
+
437
+ const SeasonalPlanModal: React.FC<{ tree: BonsaiTree, onClose: () => void, isAIConfigured: boolean }> = ({ tree, onClose, isAIConfigured }) => {
438
+ const [guideData, setGuideData] = useState<SeasonalGuide[] | null>(null);
439
+ const [isLoading, setIsLoading] = useState(true);
440
+ const [error, setError] = useState('');
441
+
442
+ useEffect(() => {
443
+ const fetchGuide = async () => {
444
+ setIsLoading(true);
445
+ setError('');
446
+
447
+ const latestAnalysis = tree.analysisHistory[0]?.analysis;
448
+ // Use the guide from the analysis if it exists
449
+ if (latestAnalysis && latestAnalysis.seasonalGuide && latestAnalysis.seasonalGuide.length > 0) {
450
+ setGuideData(latestAnalysis.seasonalGuide);
451
+ setIsLoading(false);
452
+ return;
453
+ }
454
+
455
+ // If no cached guide, check if AI is configured before trying to generate
456
+ if (!isAIConfigured) {
457
+ setError("A seasonal guide was not found in your latest analysis. Please set an API key in Settings to generate a new one.");
458
+ setIsLoading(false);
459
+ return;
460
+ }
461
+
462
+ // Fallback to generating if not found in analysis
463
+ try {
464
+ const result = await generateSeasonalGuide(tree.species, tree.location);
465
+ if (result) {
466
+ setGuideData(result);
467
+ } else {
468
+ throw new Error("Failed to get seasonal guide from the AI.");
469
+ }
470
+ } catch (e: any) {
471
+ setError(e.message);
472
+ } finally {
473
+ setIsLoading(false);
474
+ }
475
+ };
476
+ fetchGuide();
477
+ }, [tree, isAIConfigured]);
478
+
479
+ const seasonIcons: { [key: string]: React.FC<React.SVGProps<SVGSVGElement>> } = {
480
+ Spring: SunriseIcon,
481
+ Summer: SunIcon,
482
+ Autumn: WindIcon,
483
+ Winter: SnowflakeIcon,
484
+ };
485
+
486
+ return (
487
+ <div className="max-h-[70vh] overflow-y-auto pr-2">
488
+ {isLoading && <Spinner text="Yuki is reading the almanac..." />}
489
+ {error && <p className="text-center text-red-600 p-4 bg-red-50 rounded-md">{error}</p>}
490
+ {guideData && (
491
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
492
+ {guideData.sort((a,b) => ['Spring', 'Summer', 'Autumn', 'Winter'].indexOf(a.season) - ['Spring', 'Summer', 'Autumn', 'Winter'].indexOf(b.season)).map(season => {
493
+ const Icon = seasonIcons[season.season] || LeafIcon;
494
+ return (
495
+ <div key={season.season} className="bg-white rounded-xl shadow-md border border-stone-200 p-6">
496
+ <div className="flex items-center gap-3 mb-4">
497
+ <Icon className="w-7 h-7 text-green-700" />
498
+ <h3 className="text-xl font-semibold text-stone-800">{season.season}</h3>
499
+ </div>
500
+ <div className="space-y-3 text-stone-600">
501
+ <p className="italic text-stone-600 mb-4">{season.summary}</p>
502
+ <ul className="space-y-2">
503
+ {season.tasks.map(task => (
504
+ <li key={task.task} className="flex items-center justify-between text-sm">
505
+ <span>{task.task}</span>
506
+ <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>
507
+ </li>
508
+ ))}
509
+ </ul>
510
+ </div>
511
+ </div>
512
+ )
513
+ })}
514
+ </div>
515
+ )}
516
+ </div>
517
+ );
518
+ };
519
+
520
+
521
+ // --- Forms and Modals ---
522
+
523
+ const AddTreeForm: React.FC<{onAddTree: (tree: Omit<BonsaiTree, 'id' | 'logs' | 'analysisHistory'>) => void, onClose: () => void}> = ({onAddTree, onClose}) => {
524
+ const [name, setName] = useState('');
525
+ const [species, setSpecies] = useState('');
526
+ const [location, setLocation] = useState('');
527
+ const [acquiredDate, setAcquiredDate] = useState(new Date().toISOString().split('T')[0]);
528
+ const [source, setSource] = useState('');
529
+ const [initialPhoto, setInitialPhoto] = useState<string>('');
530
+ const [notes, setNotes] = useState('');
531
+ const [error, setError] = useState('');
532
+ const fileInputRef = useRef<HTMLInputElement>(null);
533
+
534
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
535
+ const file = e.target.files?.[0];
536
+ if (file) {
537
+ const reader = new FileReader();
538
+ reader.onloadend = () => setInitialPhoto((reader.result as string).split(',')[1]);
539
+ reader.readAsDataURL(file);
540
+ }
541
+ };
542
+
543
+ const handleSubmit = (e: React.FormEvent) => {
544
+ e.preventDefault();
545
+ if (!name || !species || !acquiredDate || !initialPhoto || !location) {
546
+ setError('Please fill out all required fields and upload an initial photo.');
547
+ return;
548
+ }
549
+ onAddTree({ name, species, acquiredDate, source, initialPhoto, notes, location });
550
+ onClose();
551
+ };
552
+
553
+ return (
554
+ <form onSubmit={handleSubmit} className="space-y-4">
555
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
556
+ <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" />
557
+ <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" />
558
+ <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" />
559
+ <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" />
560
+ </div>
561
+ <input type="date" value={acquiredDate} onChange={e => setAcquiredDate(e.target.value)} required className="w-full p-2 border rounded-md" />
562
+ <textarea placeholder="Initial notes..." value={notes} onChange={e => setNotes(e.target.value)} className="w-full p-2 border rounded-md" rows={3}></textarea>
563
+ <div>
564
+ <label className="block text-sm font-medium text-stone-700 mb-1">Initial Photo (Required)</label>
565
+ <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">
566
+ <div className="space-y-1 text-center">
567
+ {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" />}
568
+ <p className="text-sm text-stone-600">Click to upload an image</p>
569
+ </div>
570
+ </div>
571
+ <input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="sr-only" required/>
572
+ </div>
573
+ {error && <p className="text-red-500 text-sm">{error}</p>}
574
+ <div className="flex justify-end gap-2">
575
+ <button type="button" onClick={onClose} className="bg-stone-200 text-stone-700 px-4 py-2 rounded-md">Cancel</button>
576
+ <button type="submit" className="bg-green-700 text-white px-4 py-2 rounded-md">Add Tree</button>
577
+ </div>
578
+ </form>
579
+ );
580
+ };
581
+
582
+ const AddLogForm: React.FC<{onAddLog: (log: Omit<DiaryLog, 'id'>, analyze: boolean) => void, onClose: () => void, isAIConfigured: boolean}> = ({onAddLog, onClose, isAIConfigured}) => {
583
+ const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
584
+ const [title, setTitle] = useState('');
585
+ const [notes, setNotes] = useState('');
586
+ const [photos, setPhotos] = useState<string[]>([]);
587
+ const [tags, setTags] = useState<LogTag[]>([]);
588
+ const [requestAnalysis, setRequestAnalysis] = useState(true);
589
+ const fileInputRef = useRef<HTMLInputElement>(null);
590
+ const [error, setError] = useState('');
591
+
592
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
593
+ const files = e.target.files;
594
+ if (files) {
595
+ const filePromises = Array.from(files).map(file => {
596
+ return new Promise<string>((resolve, reject) => {
597
+ const reader = new FileReader();
598
+ reader.onloadend = () => resolve((reader.result as string).split(',')[1]);
599
+ reader.onerror = reject;
600
+ reader.readAsDataURL(file);
601
+ });
602
+ });
603
+ Promise.all(filePromises).then(base64Photos => setPhotos(p => [...p, ...base64Photos]));
604
+ }
605
+ };
606
+
607
+ const handleTagToggle = (tag: LogTag) => {
608
+ setTags(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]);
609
+ };
610
+
611
+ const handleSubmit = (e: React.FormEvent) => {
612
+ e.preventDefault();
613
+ if (!title || !date) {
614
+ setError('Please add a title and date.');
615
+ return;
616
+ }
617
+ onAddLog({ date, title, notes, photos, tags }, requestAnalysis);
618
+ onClose();
619
+ };
620
+
621
+ return (
622
+ <form onSubmit={handleSubmit} className="space-y-4">
623
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
624
+ <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" />
625
+ <input type="date" value={date} onChange={e => setDate(e.target.value)} required className="w-full p-2 border rounded-md" />
626
+ </div>
627
+ <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>
628
+
629
+ <div>
630
+ <label className="block text-sm font-medium text-stone-700 mb-1">Photos</label>
631
+ <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">
632
+ <div className="space-y-1 text-center">
633
+ <ImagePlusIcon className="mx-auto h-12 w-12 text-stone-400" />
634
+ <p className="text-sm text-stone-600">Click to upload images</p>
635
+ </div>
636
+ </div>
637
+ <input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" multiple className="sr-only"/>
638
+ {photos.length > 0 && <div className="mt-2 text-sm text-stone-600">{photos.length} photo(s) selected.</div>}
639
+ </div>
640
+
641
+ <div>
642
+ <label className="block text-sm font-medium text-stone-700 mb-1">Tags</label>
643
+ <div className="flex flex-wrap gap-2">
644
+ {Object.values(LogTag).map(tag => (
645
+ <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'}`}>
646
+ {tag}
647
+ </button>
648
+ ))}
649
+ </div>
650
+ </div>
651
+
652
+ <div className={`flex items-center gap-3 p-3 rounded-md ${isAIConfigured ? 'bg-green-50' : 'bg-stone-100'}`}>
653
+ <SparklesIcon className={`w-8 h-8 ${isAIConfigured ? 'text-green-600' : 'text-stone-400'}`}/>
654
+ <div>
655
+ <label htmlFor="ai-analysis" className={`font-medium ${isAIConfigured ? 'text-green-800' : 'text-stone-500'}`}>Request AI Analysis</label>
656
+ <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>
657
+ </div>
658
+ <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} />
659
+ </div>
660
+
661
+ {error && <p className="text-red-500 text-sm">{error}</p>}
662
+ <div className="flex justify-end gap-2">
663
+ <button type="button" onClick={onClose} className="bg-stone-200 text-stone-700 px-4 py-2 rounded-md">Cancel</button>
664
+ <button type="submit" className="bg-green-700 text-white px-4 py-2 rounded-md">Add Log</button>
665
+ </div>
666
+ </form>
667
+ );
668
+ }
669
+
670
+ const GrowthVisualizer: React.FC<{tree: BonsaiTree, onClose: () => void}> = ({ tree, onClose }) => {
671
+ const logsWithPhotos = tree.logs.filter(l => l.photos.length > 0);
672
+ const [startLogId, setStartLogId] = useState<string>(logsWithPhotos.length > 1 ? logsWithPhotos[logsWithPhotos.length - 1].id : '');
673
+ const [endLogId, setEndLogId] = useState<string>(logsWithPhotos.length > 0 ? logsWithPhotos[0].id : '');
674
+ const [analysis, setAnalysis] = useState<string | null>(null);
675
+ const [isLoading, setIsLoading] = useState(false);
676
+ const [error, setError] = useState('');
677
+
678
+ const handleGenerate = async () => {
679
+ setError('');
680
+ const startLog = logsWithPhotos.find(l => l.id === startLogId);
681
+ const endLog = logsWithPhotos.find(l => l.id === endLogId);
682
+
683
+ if (!startLog || !endLog || !startLog.photos[0] || !endLog.photos[0]) return;
684
+
685
+ setIsLoading(true);
686
+ setAnalysis(null);
687
+
688
+ const photoData = [
689
+ { date: startLog.date, image: startLog.photos[0] },
690
+ { date: endLog.date, image: endLog.photos[0] }
691
+ ].sort((a,b) => new Date(a.date).getTime() - new Date(b.date).getTime());
692
+
693
+ try {
694
+ const result = await analyzeGrowthProgression(tree.species, photoData);
695
+ setAnalysis(result || "Could not generate analysis.");
696
+ } catch (e: any) {
697
+ setError(e.message);
698
+ } finally {
699
+ setIsLoading(false);
700
+ }
701
+ };
702
+
703
+ const startPhoto = logsWithPhotos.find(l => l.id === startLogId)?.photos[0];
704
+ const endPhoto = logsWithPhotos.find(l => l.id === endLogId)?.photos[0];
705
+
706
+ return (
707
+ <div className="space-y-4">
708
+ <div className="grid grid-cols-2 gap-4">
709
+ <div>
710
+ <label className="block text-sm font-medium">Start Photo</label>
711
+ <select value={startLogId} onChange={e => setStartLogId(e.target.value)} className="w-full p-2 border rounded-md">
712
+ {logsWithPhotos.map(l => <option key={l.id} value={l.id}>{new Date(l.date).toLocaleDateString()} - {l.title}</option>)}
713
+ </select>
714
+ {startPhoto && <img src={`data:image/jpeg;base64,${startPhoto}`} className="mt-2 rounded-md w-full" />}
715
+ </div>
716
+ <div>
717
+ <label className="block text-sm font-medium">End Photo</label>
718
+ <select value={endLogId} onChange={e => setEndLogId(e.target.value)} className="w-full p-2 border rounded-md">
719
+ {logsWithPhotos.map(l => <option key={l.id} value={l.id}>{new Date(l.date).toLocaleDateString()} - {l.title}</option>)}
720
+ </select>
721
+ {endPhoto && <img src={`data:image/jpeg;base64,${endPhoto}`} className="mt-2 rounded-md w-full" />}
722
+ </div>
723
+ </div>
724
+
725
+ <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">
726
+ <SparklesIcon className="w-5 h-5" />
727
+ {isLoading ? 'Yuki is Analyzing...' : 'Generate Growth Analysis'}
728
+ </button>
729
+ {error && <p className="text-sm text-red-600 text-center">{error}</p>}
730
+ {isLoading && <Spinner text="Analyzing progression..." />}
731
+ {analysis && (
732
+ <div className="my-3 p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg">
733
+ <h4 className="font-bold text-blue-800">Progression Analysis</h4>
734
+ <p className="text-sm text-blue-700 mt-2 whitespace-pre-wrap">{analysis}</p>
735
+ </div>
736
+ )}
737
+ </div>
738
+ );
739
+ };
740
+
741
+ const Modal: React.FC<{onClose: () => void, title: string, children: React.ReactNode}> = ({ onClose, title, children }) => {
742
+ return (
743
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
744
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-4xl p-6 relative" onClick={e => e.stopPropagation()}>
745
+ <h3 className="text-xl font-bold text-stone-900 mb-4">{title}</h3>
746
+ {children}
747
+ </div>
748
+ </div>
749
+ );
750
+ };
751
+
752
+
753
+ export default BonsaiDiaryView;
views/CheckoutCancelView.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ // ====================================================================================
4
+ // NOTE: This component is no longer used in the application.
5
+ // The Stripe checkout flow has been removed.
6
+ // This file is kept to avoid breaking imports but its content is non-functional.
7
+ // ====================================================================================
8
+
9
+ const CheckoutCancelView: React.FC = () => {
10
+ return (
11
+ <div className="flex items-center justify-center h-screen bg-stone-100">
12
+ <div className="text-center p-8">
13
+ <h1 className="text-2xl font-bold">View Deprecated</h1>
14
+ <p>This view is currently not in use.</p>
15
+ </div>
16
+ </div>
17
+ );
18
+ };
19
+
20
+ export default CheckoutCancelView;
views/CheckoutSuccessView.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ // ====================================================================================
4
+ // NOTE: This component is no longer used in the application.
5
+ // The Stripe checkout flow has been removed.
6
+ // This file is kept to avoid breaking imports but its content is non-functional.
7
+ // ====================================================================================
8
+
9
+ const CheckoutSuccessView: React.FC = () => {
10
+ return (
11
+ <div className="flex items-center justify-center h-screen bg-stone-100">
12
+ <div className="text-center p-8">
13
+ <h1 className="text-2xl font-bold">View Deprecated</h1>
14
+ <p>This view is currently not in use.</p>
15
+ </div>
16
+ </div>
17
+ );
18
+ };
19
+
20
+ export default CheckoutSuccessView;
views/DesignStudioView.tsx ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+
4
+
5
+
6
+ import React, { useState, useRef, useLayoutEffect } from 'react';
7
+ import { PaletteIcon, SparklesIcon, UploadCloudIcon, AlertTriangleIcon } from '../components/icons';
8
+ import Spinner from '../components/Spinner';
9
+ import { generateStylingBlueprint, isAIConfigured } from '../services/geminiService';
10
+ import { AppStatus, AnnotationType } from '../types';
11
+ import type { StylingBlueprint, StylingAnnotation, SvgPoint, View } from '../types';
12
+
13
+ const ANNOTATION_STYLES: { [key in AnnotationType]: React.CSSProperties } = {
14
+ [AnnotationType.PruneLine]: { stroke: '#ef4444', strokeWidth: 3, strokeDasharray: '6 4', fill: 'none' },
15
+ [AnnotationType.RemoveBranch]: { stroke: '#ef4444', strokeWidth: 2, fill: 'rgba(239, 68, 68, 0.3)' },
16
+ [AnnotationType.WireDirection]: { stroke: '#3b82f6', strokeWidth: 3, fill: 'none', markerEnd: 'url(#arrow-head-blue)' },
17
+ [AnnotationType.FoliageRefinement]: { stroke: '#22c55e', strokeWidth: 3, strokeDasharray: '8 5', fill: 'rgba(34, 197, 94, 0.2)' },
18
+ [AnnotationType.JinShari]: { stroke: '#a16207', strokeWidth: 2, fill: 'rgba(161, 98, 7, 0.25)', strokeDasharray: '3 3' },
19
+ [AnnotationType.TrunkLine]: { stroke: '#f97316', strokeWidth: 4, fill: 'none', opacity: 0.8, markerEnd: 'url(#arrow-head-orange)' },
20
+ [AnnotationType.ExposeRoot]: { stroke: '#9333ea', strokeWidth: 2, fill: 'rgba(147, 51, 234, 0.2)', strokeDasharray: '5 5' },
21
+ };
22
+
23
+ const SvgAnnotation: React.FC<{ annotation: StylingAnnotation, scale: { x: number, y: number } }> = ({ annotation, scale }) => {
24
+ const style = ANNOTATION_STYLES[annotation.type];
25
+ const { type, points, path, label } = annotation;
26
+
27
+ const transformPoints = (pts: SvgPoint[]): string => {
28
+ return pts.map(p => `${p.x * scale.x},${p.y * scale.y}`).join(' ');
29
+ };
30
+
31
+ const scalePath = (pathData: string): string => {
32
+ return pathData.replace(/([0-9.]+)/g, (match, number, offset) => {
33
+ const precedingChar = pathData[offset - 1];
34
+ // Simple check: 'y' coordinate often follows 'x' or a space/comma.
35
+ // This isn't perfect but works for M, L, Q, C path commands.
36
+ const isY = precedingChar === ',' || (precedingChar === ' ' && pathData.substring(0, offset).split(' ').length % 2 === 0);
37
+ return isY ? (parseFloat(number) * scale.y).toFixed(2) : (parseFloat(number) * scale.x).toFixed(2);
38
+ });
39
+ };
40
+
41
+ const positionLabel = (): SvgPoint => {
42
+ if (points && points.length > 0) {
43
+ // Average the points to find a center for the label
44
+ const total = points.reduce((acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }), { x: 0, y: 0 });
45
+ return { x: (total.x / points.length) * scale.x, y: (total.y / points.length) * scale.y };
46
+ }
47
+ if (path) {
48
+ // Get the last point of the path for the label
49
+ const commands = path.split(' ');
50
+ const lastX = parseFloat(commands[commands.length - 2]);
51
+ const lastY = parseFloat(commands[commands.length - 1]);
52
+ return { x: lastX * scale.x, y: lastY * scale.y };
53
+ }
54
+ return { x: 0, y: 0 };
55
+ };
56
+
57
+ const labelPos = positionLabel();
58
+
59
+ const renderShape = () => {
60
+ switch (type) {
61
+ case AnnotationType.PruneLine:
62
+ return <polyline points={transformPoints(points || [])} style={style} />;
63
+ case AnnotationType.FoliageRefinement:
64
+ case AnnotationType.RemoveBranch:
65
+ case AnnotationType.JinShari:
66
+ case AnnotationType.ExposeRoot:
67
+ return <polygon points={transformPoints(points || [])} style={style} />;
68
+ case AnnotationType.WireDirection:
69
+ case AnnotationType.TrunkLine:
70
+ return <path d={scalePath(path || '')} style={style} />;
71
+ default:
72
+ return null;
73
+ }
74
+ };
75
+
76
+ return (
77
+ <g className="annotation-group transition-opacity hover:opacity-100 opacity-80">
78
+ {renderShape()}
79
+ <text x={labelPos.x + 5} y={labelPos.y - 5} fill="white" stroke="black" strokeWidth="0.5px" paintOrder="stroke" fontSize="14" fontWeight="bold" className="pointer-events-none">
80
+ {label}
81
+ </text>
82
+ </g>
83
+ );
84
+ };
85
+
86
+
87
+ const DesignStudioView: React.FC<{ setActiveView: (view: View) => void }> = ({ setActiveView }) => {
88
+ const [status, setStatus] = useState<AppStatus>(AppStatus.IDLE);
89
+ const [image, setImage] = useState<{ preview: string; base64: string } | null>(null);
90
+ const [prompt, setPrompt] = useState<string>('');
91
+ const [blueprint, setBlueprint] = useState<StylingBlueprint | null>(null);
92
+ const [error, setError] = useState<string>('');
93
+ const [viewBox, setViewBox] = useState({ width: 0, height: 0 });
94
+ const [scale, setScale] = useState({ x: 1, y: 1 });
95
+ const fileInputRef = useRef<HTMLInputElement>(null);
96
+ const imageContainerRef = useRef<HTMLDivElement>(null);
97
+ const aiConfigured = isAIConfigured();
98
+
99
+ useLayoutEffect(() => {
100
+ if (image && blueprint && imageContainerRef.current) {
101
+ const { clientWidth, clientHeight } = imageContainerRef.current;
102
+ setViewBox({ width: clientWidth, height: clientHeight });
103
+ setScale({
104
+ x: clientWidth / blueprint.canvas.width,
105
+ y: clientHeight / blueprint.canvas.height
106
+ });
107
+ }
108
+ }, [image, blueprint]);
109
+
110
+
111
+ const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
112
+ const file = event.target.files?.[0];
113
+ if (file) {
114
+ if (file.size > 4 * 1024 * 1024) {
115
+ setError("File size exceeds 4MB. Please upload a smaller image.");
116
+ return;
117
+ }
118
+ const reader = new FileReader();
119
+ reader.onloadend = () => {
120
+ const base64String = (reader.result as string).split(',')[1];
121
+ setImage({ preview: reader.result as string, base64: base64String });
122
+ setError('');
123
+ setBlueprint(null);
124
+ setStatus(AppStatus.IDLE);
125
+ };
126
+ reader.onerror = () => setError("Failed to read the file.");
127
+ reader.readAsDataURL(file);
128
+ }
129
+ };
130
+
131
+ const handleGenerate = async () => {
132
+ if (!image) {
133
+ setError("Please upload an image first.");
134
+ return;
135
+ }
136
+ if (!prompt.trim()) {
137
+ setError("Please enter a styling instruction.");
138
+ return;
139
+ }
140
+
141
+ setStatus(AppStatus.ANALYZING);
142
+ setError('');
143
+ setBlueprint(null);
144
+
145
+ try {
146
+ const result = await generateStylingBlueprint(image.base64, prompt);
147
+ if (result) {
148
+ setBlueprint(result);
149
+ setStatus(AppStatus.SUCCESS);
150
+ } else {
151
+ throw new Error('Failed to generate styling blueprint. The AI may be busy or the prompt was not specific enough. Please try again.');
152
+ }
153
+ } catch (e: any) {
154
+ setError(e.message);
155
+ setStatus(AppStatus.ERROR);
156
+ }
157
+ };
158
+
159
+ const presetPrompts = [
160
+ "Style as a classic formal upright (Chokkan) with clear, triangular lines.",
161
+ "Restyle into a dramatic cascade (Kengai) form, as if hanging off a cliff.",
162
+ "Imagine this as a windswept (Fukinagashi) tree, with all branches flowing in one direction.",
163
+ "Show a semi-cascade (Han-kengai) version.",
164
+ "Give it a more mature appearance with a denser, more refined canopy of foliage pads.",
165
+ "Incorporate some dramatic deadwood (Jin and Shari) on the trunk and branches.",
166
+ ];
167
+
168
+ return (
169
+ <div className="space-y-8 max-w-7xl mx-auto">
170
+ <header className="text-center">
171
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
172
+ <PaletteIcon className="w-8 h-8 text-purple-600" />
173
+ AI Design Studio
174
+ </h2>
175
+ <p className="mt-4 text-lg leading-8 text-stone-600 max-w-3xl mx-auto">
176
+ Describe your desired outcome, and Yuki will generate a visual guide on your photo. Using SVG graphics and text labels, she shows you exactly where to prune, wire, or shape your tree to achieve your vision.
177
+ </p>
178
+ </header>
179
+
180
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
181
+ {/* Left Column: Controls */}
182
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-4 lg:col-span-1">
183
+ <div>
184
+ <label className="block text-sm font-medium text-stone-900">1. Upload Photo</label>
185
+ <div onClick={() => fileInputRef.current?.click()} className="mt-1 flex justify-center p-4 rounded-lg border-2 border-dashed border-stone-300 hover:border-purple-600 transition-colors cursor-pointer">
186
+ <div className="text-center">
187
+ {image ? <p className="text-green-700 font-semibold">Image Loaded!</p> : <UploadCloudIcon className="mx-auto h-10 w-10 text-stone-400" />}
188
+ <p className="mt-1 text-sm text-stone-600">{image ? 'Click to change image' : 'Click to upload'}</p>
189
+ </div>
190
+ <input ref={fileInputRef} type="file" className="sr-only" onChange={handleFileChange} accept="image/png, image/jpeg" />
191
+ </div>
192
+ </div>
193
+
194
+ <div>
195
+ <label htmlFor="style-prompt" className="block text-sm font-medium text-stone-900">2. Describe Your Goal</label>
196
+ <textarea
197
+ id="style-prompt"
198
+ rows={3}
199
+ value={prompt}
200
+ onChange={e => setPrompt(e.target.value)}
201
+ className="mt-1 block w-full rounded-md border-stone-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
202
+ placeholder="e.g., 'Make it look like a windswept tree'"
203
+ />
204
+ </div>
205
+ <div className="space-y-2">
206
+ <p className="text-sm font-medium text-stone-700">Or try a preset goal:</p>
207
+ <div className="flex flex-wrap gap-2">
208
+ {presetPrompts.map((p, i) => (
209
+ <button key={i} onClick={() => setPrompt(p)} className="text-xs bg-stone-100 text-stone-700 px-3 py-1 rounded-full hover:bg-purple-100 hover:text-purple-800 transition-colors">
210
+ {p.split('(')[0].trim()}
211
+ </button>
212
+ ))}
213
+ </div>
214
+ </div>
215
+ <button onClick={handleGenerate} disabled={status === AppStatus.ANALYZING || !image || !aiConfigured} className="w-full flex items-center justify-center gap-2 rounded-md bg-purple-600 px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-purple-500 disabled:bg-stone-400 disabled:cursor-not-allowed">
216
+ <SparklesIcon className="w-5 h-5"/> {status === AppStatus.ANALYZING ? 'Generating Blueprint...' : '3. Generate Blueprint'}
217
+ </button>
218
+ {error && <p className="text-sm text-red-600 mt-2 bg-red-50 p-3 rounded-md">{error}</p>}
219
+ {!aiConfigured && (
220
+ <div className="mt-4 p-3 bg-yellow-50 text-yellow-800 rounded-lg border border-yellow-200 text-center">
221
+ <p className="text-sm">
222
+ Please set your Gemini API key in the{' '}
223
+ <button onClick={() => setActiveView('settings')} className="font-bold underline hover:text-yellow-900">
224
+ Settings page
225
+ </button>
226
+ {' '}to enable this feature.
227
+ </p>
228
+ </div>
229
+ )}
230
+ </div>
231
+
232
+ {/* Right Column: Canvas */}
233
+ <div className="bg-white p-4 rounded-xl shadow-lg border border-stone-200 lg:col-span-2">
234
+ <div ref={imageContainerRef} className="relative w-full aspect-w-4 aspect-h-3 bg-stone-100 rounded-lg">
235
+ {image ? (
236
+ <>
237
+ <img src={image.preview} alt="Bonsai" className="w-full h-full object-contain rounded-lg" />
238
+ {blueprint && (
239
+ <svg
240
+ className="absolute top-0 left-0 w-full h-full"
241
+ viewBox={`0 0 ${viewBox.width} ${viewBox.height}`}
242
+ xmlns="http://www.w3.org/2000/svg"
243
+ >
244
+ <defs>
245
+ <marker id="arrow-head-blue" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
246
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#3b82f6" />
247
+ </marker>
248
+ <marker id="arrow-head-orange" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
249
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#f97316" />
250
+ </marker>
251
+ </defs>
252
+ {blueprint.annotations.map((anno, i) => (
253
+ <SvgAnnotation key={i} annotation={anno} scale={scale} />
254
+ ))}
255
+ </svg>
256
+ )}
257
+ </>
258
+ ) : (
259
+ <div className="flex items-center justify-center h-full">
260
+ <p className="text-stone-500">Your image and blueprint will appear here</p>
261
+ </div>
262
+ )}
263
+ {status === AppStatus.ANALYZING && <div className="absolute inset-0 flex items-center justify-center bg-white/75"><Spinner text="Yuki is sketching your vision..." /></div>}
264
+ </div>
265
+ {blueprint && status === AppStatus.SUCCESS && (
266
+ <div className="mt-4 p-4 bg-purple-50 border-l-4 border-purple-500 rounded-r-lg">
267
+ <h4 className="font-bold text-purple-800">Yuki's Summary</h4>
268
+ <p className="text-sm text-purple-700 mt-1">{blueprint.summary}</p>
269
+ </div>
270
+ )}
271
+ </div>
272
+ </div>
273
+ </div>
274
+ );
275
+ };
276
+
277
+ export default DesignStudioView;
views/FertilizerMixerView.tsx ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useMemo } from 'react';
3
+ import { BeakerIcon } from '../components/icons';
4
+
5
+ type WaterUnit = 'L' | 'gal';
6
+ type ConcentrateUnit = 'ml' | 'tsp' | 'tbsp';
7
+ type Strength = 'full' | 'half' | 'quarter';
8
+
9
+ const STRENGTH_MULTIPLIERS: Record<Strength, number> = {
10
+ full: 1,
11
+ half: 0.5,
12
+ quarter: 0.25,
13
+ };
14
+
15
+ const CONVERSION_TO_ML = {
16
+ ml: 1,
17
+ tsp: 4.92892,
18
+ tbsp: 14.7868,
19
+ };
20
+
21
+ const CONVERSION_TO_LITER = {
22
+ L: 1,
23
+ gal: 3.78541,
24
+ };
25
+
26
+ const FertilizerMixerView: React.FC = () => {
27
+ const [waterAmount, setWaterAmount] = useState(1);
28
+ const [waterUnit, setWaterUnit] = useState<WaterUnit>('gal');
29
+ const [concentrateAmount, setConcentrateAmount] = useState(1);
30
+ const [concentrateUnit, setConcentrateUnit] = useState<ConcentrateUnit>('tsp');
31
+ const [strength, setStrength] = useState<Strength>('half');
32
+ const [baseWaterAmount, setBaseWaterAmount] = useState(1);
33
+ const [baseWaterUnit, setBaseWaterUnit] = useState<WaterUnit>('gal');
34
+
35
+ const result = useMemo(() => {
36
+ try {
37
+ const desiredWaterL = waterAmount * CONVERSION_TO_LITER[waterUnit];
38
+ const baseWaterL = baseWaterAmount * CONVERSION_TO_LITER[baseWaterUnit];
39
+ const baseConcentrateMl = concentrateAmount * CONVERSION_TO_ML[concentrateUnit];
40
+
41
+ if (baseWaterL === 0) return { amount: 0, unit: 'ml' };
42
+
43
+ const ratio = baseConcentrateMl / baseWaterL; // ml of concentrate per L of water
44
+ const strengthMultiplier = STRENGTH_MULTIPLIERS[strength];
45
+
46
+ const neededMl = desiredWaterL * ratio * strengthMultiplier;
47
+
48
+ // Return result in a user-friendly unit
49
+ if (neededMl < 15 && concentrateUnit !== 'ml') return { amount: neededMl / CONVERSION_TO_ML.tsp, unit: 'tsp' };
50
+ if (neededMl < 45 && concentrateUnit !== 'ml') return { amount: neededMl / CONVERSION_TO_ML.tbsp, unit: 'tbsp' };
51
+ return { amount: neededMl, unit: 'ml' };
52
+
53
+ } catch (e) {
54
+ return { amount: 0, unit: 'ml' };
55
+ }
56
+ }, [waterAmount, waterUnit, concentrateAmount, concentrateUnit, strength, baseWaterAmount, baseWaterUnit]);
57
+
58
+ const renderUnitSelector = (value: string, setter: (val: any) => void, units: string[]) => (
59
+ <div className="flex gap-1 bg-stone-100 p-1 rounded-lg">
60
+ {units.map(unit => (
61
+ <button key={unit} onClick={() => setter(unit)} className={`px-3 py-1 text-sm font-medium rounded-md ${value === unit ? 'bg-white shadow' : ''}`}>
62
+ {unit}
63
+ </button>
64
+ ))}
65
+ </div>
66
+ );
67
+
68
+ return (
69
+ <div className="space-y-8 max-w-2xl mx-auto">
70
+ <header className="text-center">
71
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
72
+ <BeakerIcon className="w-8 h-8 text-cyan-600" />
73
+ Fertilizer Mixer
74
+ </h2>
75
+ <p className="mt-4 text-lg leading-8 text-stone-600">
76
+ Calculate the perfect fertilizer dilution every time. No more over or under-feeding.
77
+ </p>
78
+ </header>
79
+
80
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
81
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-6">
82
+ <div>
83
+ <h3 className="font-semibold text-stone-800 mb-2">1. Dosage from Bottle</h3>
84
+ <p className="text-xs text-stone-500 mb-2">Enter the recommended full-strength dose from the fertilizer label.</p>
85
+ <div className="flex items-center gap-2">
86
+ <input type="number" value={concentrateAmount} onChange={e => setConcentrateAmount(Number(e.target.value))} className="w-full p-2 border rounded-md" />
87
+ {renderUnitSelector(concentrateUnit, setConcentrateUnit, ['ml', 'tsp', 'tbsp'])}
88
+ </div>
89
+ <p className="text-center my-1 text-stone-600 font-medium">per</p>
90
+ <div className="flex items-center gap-2">
91
+ <input type="number" value={baseWaterAmount} onChange={e => setBaseWaterAmount(Number(e.target.value))} className="w-full p-2 border rounded-md" />
92
+ {renderUnitSelector(baseWaterUnit, setBaseWaterUnit, ['L', 'gal'])}
93
+ </div>
94
+ </div>
95
+ <div>
96
+ <h3 className="font-semibold text-stone-800 mb-2">2. Your Watering Can</h3>
97
+ <p className="text-xs text-stone-500 mb-2">How much water are you preparing?</p>
98
+ <div className="flex items-center gap-2">
99
+ <input type="number" value={waterAmount} onChange={e => setWaterAmount(Number(e.target.value))} className="w-full p-2 border rounded-md" />
100
+ {renderUnitSelector(waterUnit, setWaterUnit, ['L', 'gal'])}
101
+ </div>
102
+ </div>
103
+ <div>
104
+ <h3 className="font-semibold text-stone-800 mb-2">3. Desired Strength</h3>
105
+ <div className="flex gap-1 bg-stone-100 p-1 rounded-lg">
106
+ <button onClick={() => setStrength('full')} className={`flex-1 py-2 text-sm font-medium rounded-md ${strength === 'full' ? 'bg-white shadow' : ''}`}>Full (100%)</button>
107
+ <button onClick={() => setStrength('half')} className={`flex-1 py-2 text-sm font-medium rounded-md ${strength === 'half' ? 'bg-white shadow' : ''}`}>Half (50%)</button>
108
+ <button onClick={() => setStrength('quarter')} className={`flex-1 py-2 text-sm font-medium rounded-md ${strength === 'quarter' ? 'bg-white shadow' : ''}`}>Quarter (25%)</button>
109
+ </div>
110
+ </div>
111
+ </div>
112
+
113
+ <div className="bg-white p-6 rounded-xl shadow-lg border-2 border-cyan-600 space-y-4 text-center">
114
+ <h3 className="text-xl font-bold text-stone-900">Your Custom Mix</h3>
115
+ <p className="text-stone-600">Add this much concentrate to your watering can:</p>
116
+ <div>
117
+ <p className="text-5xl font-bold text-cyan-700">{result.amount.toFixed(2)}</p>
118
+ <p className="text-lg text-stone-600">{result.unit}</p>
119
+ </div>
120
+ <div className="mt-4 pt-4 border-t-2 border-dashed">
121
+ <p className="text-sm text-stone-700">
122
+ This creates a {strength}-strength solution of {waterAmount} {waterUnit} of fertilizer.
123
+ </p>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ );
129
+ };
130
+
131
+ export default FertilizerMixerView;
views/HealthCheckView.tsx ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+
4
+
5
+ import React, { useState, useCallback, useRef, useEffect } from 'react';
6
+ import { useLocalStorage } from '../hooks/useLocalStorage';
7
+ import { runHealthCheck, isAIConfigured } from '../services/geminiService';
8
+ import type { View, HealthCheckResult, BonsaiTree, DiaryLog } from '../types';
9
+ import { AppStatus, LogTag } from '../types';
10
+ import { StethoscopeIcon, LeafIcon, DropletIcon, BugIcon, BonsaiIcon, SparklesIcon, AlertTriangleIcon, UploadCloudIcon, CheckCircleIcon, ArrowLeftIcon } from '../components/icons';
11
+ import Spinner from '../components/Spinner';
12
+
13
+ type Stage = 'selecting_problem' | 'uploading_photo' | 'analyzing' | 'results';
14
+
15
+ const problemCategories = [
16
+ { name: 'Leaf Discoloration', description: 'Yellowing, browning, or strange colors on leaves.', icon: LeafIcon, instruction: "Take a clear, well-lit photo of the discolored leaves. Show both the top and underside if possible." },
17
+ { name: 'Spots or Residue', description: 'Powdery mildew, black spots, or sticky residue.', icon: DropletIcon, instruction: "Get a close-up of the spots or residue. Try to have a healthy leaf in the background for comparison." },
18
+ { name: 'Pests or Damage', description: 'Visible insects, webbing, or chewed leaves.', icon: BugIcon, instruction: "Photograph the pests or the damage they've caused. If the pest is tiny, get as close as you can while maintaining focus." },
19
+ { name: 'Wilting or Drooping', description: 'Leaves or branches are losing turgidity and hanging down.', icon: BonsaiIcon, instruction: "Show the entire wilting branch or section of the tree. Also, include a photo of the soil surface if possible." },
20
+ { name: 'General Weakness', description: 'Overall lack of vigor, poor growth, or branch dieback.', icon: AlertTriangleIcon, instruction: "Take a photo of the entire tree so its overall structure and condition are visible." },
21
+ ];
22
+
23
+ const HealthCheckView: React.FC<{ setActiveView: (view: View) => void }> = ({ setActiveView }) => {
24
+ const [stage, setStage] = useState<Stage>('selecting_problem');
25
+ const [selectedCategory, setSelectedCategory] = useState<(typeof problemCategories)[0] | null>(null);
26
+ const [trees, setTrees] = useLocalStorage<BonsaiTree[]>('bonsai-diary-trees', []);
27
+ const [selectedTreeId, setSelectedTreeId] = useState<string>('');
28
+ const [treeInfo, setTreeInfo] = useState({ species: '', location: '' });
29
+ const [image, setImage] = useState<{ preview: string; base64: string } | null>(null);
30
+ const [result, setResult] = useState<HealthCheckResult | null>(null);
31
+ const [error, setError] = useState<string>('');
32
+ const fileInputRef = useRef<HTMLInputElement>(null);
33
+ const aiConfigured = isAIConfigured();
34
+
35
+ useEffect(() => {
36
+ if (selectedTreeId) {
37
+ const tree = trees.find(t => t.id === selectedTreeId);
38
+ if (tree) {
39
+ setTreeInfo({ species: tree.species, location: tree.location });
40
+ }
41
+ } else {
42
+ setTreeInfo({ species: '', location: '' });
43
+ }
44
+ }, [selectedTreeId, trees]);
45
+
46
+
47
+ const handleSelectCategory = (category: (typeof problemCategories)[0]) => {
48
+ setSelectedCategory(category);
49
+ setStage('uploading_photo');
50
+ };
51
+
52
+ const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
53
+ const file = event.target.files?.[0];
54
+ if (file) {
55
+ if (file.size > 4 * 1024 * 1024) { // 4MB limit
56
+ setError("File size exceeds 4MB. Please upload a smaller image.");
57
+ return;
58
+ }
59
+ const reader = new FileReader();
60
+ reader.onloadend = () => {
61
+ const base64String = (reader.result as string).split(',')[1];
62
+ setImage({ preview: reader.result as string, base64: base64String });
63
+ setError('');
64
+ };
65
+ reader.onerror = () => setError("Failed to read the file.");
66
+ reader.readAsDataURL(file);
67
+ }
68
+ };
69
+
70
+ const handleRunAnalysis = async () => {
71
+ if (!image || !treeInfo.species || !treeInfo.location || !selectedCategory) {
72
+ setError("Please provide all required information: an image, species, and location.");
73
+ return;
74
+ }
75
+ setStage('analyzing');
76
+ setError('');
77
+
78
+ try {
79
+ const analysisResult = await runHealthCheck(image.base64, treeInfo.species, treeInfo.location, selectedCategory.name);
80
+ if(analysisResult) {
81
+ setResult(analysisResult);
82
+ setStage('results');
83
+ } else {
84
+ throw new Error("Failed to get a diagnosis from the AI. It might be busy, or the image could not be processed. Please try again.");
85
+ }
86
+ } catch (e: any) {
87
+ setError(e.message);
88
+ setStage('uploading_photo');
89
+ }
90
+ };
91
+
92
+ const handleSaveToLog = () => {
93
+ if (!selectedTreeId || !result) {
94
+ alert("No tree selected or no result to save.");
95
+ return;
96
+ };
97
+
98
+ const newLog: DiaryLog = {
99
+ id: `log-${Date.now()}`,
100
+ date: new Date().toISOString(),
101
+ title: `Health Check: ${result.probableCause}`,
102
+ notes: `Ran a diagnostic for "${selectedCategory?.name}". The AI diagnosed the issue with ${result.confidence} confidence.`,
103
+ photos: image ? [image.base64] : [],
104
+ tags: [LogTag.HealthDiagnosis],
105
+ healthCheckResult: result
106
+ };
107
+
108
+ const treeToUpdate = trees.find(t => t.id === selectedTreeId);
109
+ if (treeToUpdate) {
110
+ const updatedLogs = [newLog, ...treeToUpdate.logs].sort((a,b) => new Date(b.date).getTime() - new Date(a.date).getTime());
111
+ const updatedTree = { ...treeToUpdate, logs: updatedLogs };
112
+ const newTreeList = trees.map(t => t.id === selectedTreeId ? updatedTree : t);
113
+
114
+ const storageKey = `yuki-app-bonsai-diary-trees`;
115
+ window.localStorage.setItem(storageKey, JSON.stringify(newTreeList));
116
+
117
+ alert("Diagnosis saved to your tree's log!");
118
+ setActiveView('garden');
119
+ }
120
+ };
121
+
122
+ const reset = () => {
123
+ setStage('selecting_problem');
124
+ setSelectedCategory(null);
125
+ setImage(null);
126
+ setResult(null);
127
+ setError('');
128
+ // Keep tree selection
129
+ };
130
+
131
+ const renderHeader = () => (
132
+ <header className="text-center">
133
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
134
+ <StethoscopeIcon className="w-8 h-8 text-red-600" />
135
+ Bonsai Health Check-up
136
+ </h2>
137
+ <p className="mt-4 text-lg leading-8 text-stone-600 max-w-2xl mx-auto">
138
+ Get a quick, focused diagnosis for a specific problem with your bonsai.
139
+ </p>
140
+ </header>
141
+ );
142
+
143
+ const renderContent = () => {
144
+ switch(stage) {
145
+ case 'selecting_problem':
146
+ return (
147
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200">
148
+ <h3 className="text-xl font-semibold text-center text-stone-800 mb-2">What seems to be the problem?</h3>
149
+ <p className="text-center text-stone-600 mb-6">Select a category to begin the diagnosis.</p>
150
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
151
+ {problemCategories.map(cat => {
152
+ const Icon = cat.icon;
153
+ return (
154
+ <div key={cat.name} onClick={() => handleSelectCategory(cat)} className="bg-stone-50 rounded-lg p-6 border-2 border-transparent hover:border-green-600 hover:bg-white cursor-pointer transition-all duration-200 text-center flex flex-col items-center shadow-sm hover:shadow-xl">
155
+ <Icon className="w-12 h-12 text-green-700 mb-3" />
156
+ <h4 className="font-bold text-stone-900">{cat.name}</h4>
157
+ <p className="text-sm text-stone-600 mt-1">{cat.description}</p>
158
+ </div>
159
+ )})}
160
+ </div>
161
+ </div>
162
+ );
163
+
164
+ case 'uploading_photo':
165
+ return (
166
+ <div className="bg-white p-8 rounded-2xl shadow-lg border border-stone-200 max-w-2xl mx-auto space-y-6">
167
+ <button onClick={() => setStage('selecting_problem')} className="flex items-center gap-2 text-sm font-semibold text-green-700 hover:text-green-600">
168
+ <ArrowLeftIcon className="w-5 h-5"/> Back to Categories
169
+ </button>
170
+ <div>
171
+ <h3 className="text-2xl font-bold text-stone-800">{selectedCategory?.name}</h3>
172
+ <p className="text-stone-600 mt-2"><strong className="text-stone-800">Photo Instructions:</strong> {selectedCategory?.instruction}</p>
173
+ </div>
174
+ <div onClick={() => fileInputRef.current?.click()} className="mt-2 flex justify-center rounded-lg border-2 border-dashed border-stone-300 px-6 py-10 hover:border-green-600 transition-colors cursor-pointer">
175
+ <div className="text-center">
176
+ {image ? <img src={image.preview} alt="Bonsai preview" className="mx-auto h-40 w-auto rounded-md object-cover" /> : (
177
+ <>
178
+ <UploadCloudIcon className="mx-auto h-12 w-12 text-stone-400" />
179
+ <p className="mt-2 text-sm font-semibold text-green-700">Upload a file or drag and drop</p>
180
+ <p className="text-xs text-stone-500">PNG, JPG up to 4MB</p>
181
+ </> )}
182
+ </div>
183
+ <input ref={fileInputRef} type="file" className="sr-only" onChange={handleFileChange} accept="image/png, image/jpeg" />
184
+ </div>
185
+ <div className="space-y-4">
186
+ <select value={selectedTreeId} onChange={(e) => setSelectedTreeId(e.target.value)} className="w-full p-2 border rounded-md" required>
187
+ <option value="">Select a tree from your garden...</option>
188
+ {trees.map(tree => <option key={tree.id} value={tree.id}>{tree.name} ({tree.species})</option>)}
189
+ <option value="new">-- This is a new tree --</option>
190
+ </select>
191
+ {selectedTreeId === 'new' && (
192
+ <div className="grid grid-cols-2 gap-4">
193
+ <input type="text" placeholder="Species" value={treeInfo.species} onChange={(e) => setTreeInfo(t => ({...t, species: e.target.value}))} className="w-full p-2 border rounded-md"/>
194
+ <input type="text" placeholder="Location" value={treeInfo.location} onChange={(e) => setTreeInfo(t => ({...t, location: e.target.value}))} className="w-full p-2 border rounded-md"/>
195
+ </div>
196
+ )}
197
+ </div>
198
+ {error && <p className="text-sm text-red-600">{error}</p>}
199
+ <button onClick={handleRunAnalysis} disabled={!image || !aiConfigured} className="w-full flex items-center justify-center gap-2 rounded-md bg-red-600 px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-red-500 disabled:bg-stone-400 disabled:cursor-not-allowed">
200
+ <StethoscopeIcon className="w-5 h-5"/> Get Diagnosis
201
+ </button>
202
+ {!aiConfigured && (
203
+ <div className="mt-4 p-3 bg-yellow-50 text-yellow-800 rounded-lg border border-yellow-200 text-center">
204
+ <p className="text-sm">
205
+ AI features are disabled. Please set your Gemini API key in the{' '}
206
+ <button onClick={() => setActiveView('settings')} className="font-bold underline hover:text-yellow-900">
207
+ Settings page
208
+ </button>.
209
+ </p>
210
+ </div>
211
+ )}
212
+ </div>
213
+ );
214
+
215
+ case 'analyzing':
216
+ return <Spinner text={`Yuki is diagnosing the ${selectedCategory?.name.toLowerCase()}...`} />;
217
+
218
+ case 'results':
219
+ if (!result) return null;
220
+ const confidenceColor = result.confidence === 'High' ? 'bg-red-100 text-red-800' : result.confidence === 'Medium' ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800';
221
+ return (
222
+ <div className="bg-white p-8 rounded-2xl shadow-lg border border-stone-200 max-w-3xl mx-auto space-y-6">
223
+ <h3 className="text-2xl font-bold text-stone-800">Diagnosis Complete</h3>
224
+ <div className="p-4 bg-stone-50 rounded-lg border">
225
+ <div className="flex justify-between items-baseline">
226
+ <h4 className="text-xl font-bold text-stone-900">{result.probableCause}</h4>
227
+ <span className={`px-2 py-0.5 text-xs font-medium rounded-full ${confidenceColor}`}>{result.confidence} Confidence</span>
228
+ </div>
229
+ <p className="mt-2 text-stone-600">{result.explanation}</p>
230
+ </div>
231
+
232
+ <div>
233
+ <h4 className="text-lg font-semibold text-stone-800 mb-2">Treatment Plan</h4>
234
+ <ol className="space-y-4">
235
+ {result.treatmentPlan.map(step => (
236
+ <li key={step.step} className="flex gap-4">
237
+ <div className="flex-shrink-0 w-8 h-8 bg-green-600 text-white rounded-full flex items-center justify-center font-bold">{step.step}</div>
238
+ <div>
239
+ <p className="font-bold text-stone-800">{step.action}</p>
240
+ <p className="text-stone-600">{step.details}</p>
241
+ </div>
242
+ </li>))}
243
+ </ol>
244
+ </div>
245
+
246
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
247
+ <div className="p-4 bg-green-50 rounded-lg">
248
+ <h4 className="font-semibold text-green-800">Organic Alternatives</h4>
249
+ <p className="text-sm text-green-700 mt-1">{result.organicAlternatives}</p>
250
+ </div>
251
+ <div className="p-4 bg-blue-50 rounded-lg">
252
+ <h4 className="font-semibold text-blue-800">Future Prevention</h4>
253
+ <p className="text-sm text-blue-700 mt-1">{result.preventativeMeasures}</p>
254
+ </div>
255
+ </div>
256
+
257
+ <div className="flex flex-col sm:flex-row gap-4 pt-6 border-t">
258
+ <button onClick={reset} className="flex-1 w-full bg-stone-200 text-stone-700 font-semibold py-3 px-6 rounded-lg hover:bg-stone-300 transition-colors">Run New Diagnosis</button>
259
+ {selectedTreeId && selectedTreeId !== 'new' && (
260
+ <button onClick={handleSaveToLog} className="flex-1 w-full flex items-center justify-center gap-2 rounded-md bg-green-700 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-green-600">
261
+ <CheckCircleIcon className="w-5 h-5"/> Save to Garden Log
262
+ </button>
263
+ )}
264
+ </div>
265
+ </div>
266
+ );
267
+ }
268
+ }
269
+
270
+ return (
271
+ <div className="space-y-8">
272
+ {renderHeader()}
273
+ <div>{renderContent()}</div>
274
+ </div>
275
+ );
276
+ };
277
+
278
+ export default HealthCheckView;
views/LoginView.tsx ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useContext } from 'react';
2
+ import { SparklesIcon } from '../components/icons';
3
+ import { AuthContext } from '../context/AuthContext';
4
+ import { KOFI_PAGE_URL } from '../config';
5
+
6
+ const LoginView: React.FC = () => {
7
+ const { login } = useContext(AuthContext);
8
+ const [name, setName] = useState('');
9
+ const [secret, setSecret] = useState('');
10
+ const [error, setError] = useState('');
11
+ const [isLoading, setIsLoading] = useState(false);
12
+
13
+ const handleLogin = (e: React.FormEvent) => {
14
+ e.preventDefault();
15
+ setError('');
16
+ setIsLoading(true);
17
+
18
+ setTimeout(() => {
19
+ const success = login(name, secret);
20
+ if (!success) {
21
+ setError('Invalid credentials. Please check your login name and secret password.');
22
+ }
23
+ // On success, the App component will automatically switch views.
24
+ setIsLoading(false);
25
+ }, 500); // Artificial delay for better UX
26
+ };
27
+
28
+ return (
29
+ <div className="flex items-center justify-center h-screen bg-stone-100">
30
+ <div className="w-full max-w-md p-8 space-y-6 bg-white rounded-2xl shadow-xl text-center border border-stone-200">
31
+ <div className="flex flex-col items-center">
32
+ <SparklesIcon className="h-16 w-16 text-green-700" />
33
+ <h1 className="mt-4 text-3xl font-bold text-stone-800">Welcome to Yuki</h1>
34
+ <p className="mt-2 text-stone-600">Enter the credentials you received after your purchase.</p>
35
+ </div>
36
+
37
+ <form onSubmit={handleLogin} className="space-y-4">
38
+ <div>
39
+ <input
40
+ type="text"
41
+ value={name}
42
+ onChange={(e) => setName(e.target.value)}
43
+ className="block w-full rounded-md border-0 py-2.5 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-green-600 sm:text-sm sm:leading-6"
44
+ placeholder="Login Name"
45
+ required
46
+ />
47
+ </div>
48
+ <div>
49
+ <input
50
+ type="password"
51
+ value={secret}
52
+ onChange={(e) => setSecret(e.target.value)}
53
+ className="block w-full rounded-md border-0 py-2.5 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-green-600 sm:text-sm sm:leading-6"
54
+ placeholder="Secret Password"
55
+ required
56
+ />
57
+ </div>
58
+
59
+ {error && <p className="text-sm text-red-600">{error}</p>}
60
+
61
+ <button
62
+ type="submit"
63
+ disabled={isLoading}
64
+ className="w-full bg-green-700 text-white font-bold py-3 px-6 rounded-lg hover:bg-green-600 transition-colors shadow-lg hover:shadow-xl disabled:bg-stone-400"
65
+ >
66
+ {isLoading ? 'Unlocking...' : 'Unlock'}
67
+ </button>
68
+ </form>
69
+
70
+ <p className="text-xs text-stone-500">
71
+ Don't have access?{' '}
72
+ <a
73
+ href={KOFI_PAGE_URL}
74
+ target="_blank"
75
+ rel="noopener noreferrer"
76
+ className="font-semibold hover:underline text-green-700"
77
+ >
78
+ Purchase on Ko-fi
79
+ </a>.
80
+ </p>
81
+ </div>
82
+ </div>
83
+ );
84
+ };
85
+
86
+ export default LoginView;
views/NebariDeveloperView.tsx ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+ import React, { useState, useRef, useLayoutEffect } from 'react';
4
+ import { RootsIcon, SparklesIcon, UploadCloudIcon, AlertTriangleIcon } from '../components/icons';
5
+ import Spinner from '../components/Spinner';
6
+ import { generateNebariBlueprint, isAIConfigured } from '../services/geminiService';
7
+ import { AppStatus, AnnotationType } from '../types';
8
+ import type { StylingBlueprint, StylingAnnotation, SvgPoint, View } from '../types';
9
+
10
+ const ANNOTATION_STYLES: { [key in AnnotationType]: React.CSSProperties } = {
11
+ [AnnotationType.PruneLine]: { stroke: '#ef4444', strokeWidth: 3, strokeDasharray: '6 4', fill: 'none' },
12
+ [AnnotationType.RemoveBranch]: { stroke: '#ef4444', strokeWidth: 2, fill: 'rgba(239, 68, 68, 0.3)' },
13
+ [AnnotationType.WireDirection]: { stroke: '#3b82f6', strokeWidth: 3, fill: 'none', markerEnd: 'url(#arrow-head-blue)' },
14
+ [AnnotationType.FoliageRefinement]: { stroke: '#22c55e', strokeWidth: 3, strokeDasharray: '8 5', fill: 'rgba(34, 197, 94, 0.2)' },
15
+ [AnnotationType.JinShari]: { stroke: '#a16207', strokeWidth: 2, fill: 'rgba(161, 98, 7, 0.25)', strokeDasharray: '3 3' },
16
+ [AnnotationType.TrunkLine]: { stroke: '#f97316', strokeWidth: 4, fill: 'none', opacity: 0.8, markerEnd: 'url(#arrow-head-orange)' },
17
+ [AnnotationType.ExposeRoot]: { stroke: '#9333ea', strokeWidth: 2, fill: 'rgba(147, 51, 234, 0.2)', strokeDasharray: '5 5' },
18
+ };
19
+
20
+ const SvgAnnotation: React.FC<{ annotation: StylingAnnotation, scale: { x: number, y: number } }> = ({ annotation, scale }) => {
21
+ const style = ANNOTATION_STYLES[annotation.type];
22
+ const { type, points, path, label } = annotation;
23
+
24
+ const transformPoints = (pts: SvgPoint[]): string => {
25
+ return pts.map(p => `${p.x * scale.x},${p.y * scale.y}`).join(' ');
26
+ };
27
+
28
+ const scalePath = (pathData: string): string => {
29
+ return pathData.replace(/([0-9.]+)/g, (match, number, offset) => {
30
+ const precedingChar = pathData[offset - 1];
31
+ const isY = precedingChar === ',' || (precedingChar === ' ' && pathData.substring(0, offset).split(' ').length % 2 === 0);
32
+ return isY ? (parseFloat(number) * scale.y).toFixed(2) : (parseFloat(number) * scale.x).toFixed(2);
33
+ });
34
+ };
35
+
36
+ const positionLabel = (): SvgPoint => {
37
+ if (points && points.length > 0) {
38
+ const total = points.reduce((acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }), { x: 0, y: 0 });
39
+ return { x: (total.x / points.length) * scale.x, y: (total.y / points.length) * scale.y };
40
+ }
41
+ if (path) {
42
+ const commands = path.split(' ');
43
+ const lastX = parseFloat(commands[commands.length - 2]);
44
+ const lastY = parseFloat(commands[commands.length - 1]);
45
+ return { x: lastX * scale.x, y: lastY * scale.y };
46
+ }
47
+ return { x: 0, y: 0 };
48
+ };
49
+
50
+ const labelPos = positionLabel();
51
+
52
+ const renderShape = () => {
53
+ switch (type) {
54
+ case AnnotationType.PruneLine:
55
+ return <polyline points={transformPoints(points || [])} style={style} />;
56
+ case AnnotationType.FoliageRefinement:
57
+ case AnnotationType.RemoveBranch:
58
+ case AnnotationType.JinShari:
59
+ case AnnotationType.ExposeRoot:
60
+ return <polygon points={transformPoints(points || [])} style={style} />;
61
+ case AnnotationType.WireDirection:
62
+ case AnnotationType.TrunkLine:
63
+ return <path d={scalePath(path || '')} style={style} />;
64
+ default:
65
+ return null;
66
+ }
67
+ };
68
+
69
+ return (
70
+ <g className="annotation-group transition-opacity hover:opacity-100 opacity-80">
71
+ {renderShape()}
72
+ <text x={labelPos.x + 5} y={labelPos.y - 5} fill="white" stroke="black" strokeWidth="0.5px" paintOrder="stroke" fontSize="14" fontWeight="bold" className="pointer-events-none">
73
+ {label}
74
+ </text>
75
+ </g>
76
+ );
77
+ };
78
+
79
+
80
+ const NebariDeveloperView: React.FC<{ setActiveView: (view: View) => void }> = ({ setActiveView }) => {
81
+ const [status, setStatus] = useState<AppStatus>(AppStatus.IDLE);
82
+ const [image, setImage] = useState<{ preview: string; base64: string } | null>(null);
83
+ const [blueprint, setBlueprint] = useState<StylingBlueprint | null>(null);
84
+ const [error, setError] = useState<string>('');
85
+ const [viewBox, setViewBox] = useState({ width: 0, height: 0 });
86
+ const [scale, setScale] = useState({ x: 1, y: 1 });
87
+ const fileInputRef = useRef<HTMLInputElement>(null);
88
+ const imageContainerRef = useRef<HTMLDivElement>(null);
89
+ const aiConfigured = isAIConfigured();
90
+
91
+ useLayoutEffect(() => {
92
+ const handleResize = () => {
93
+ if (image && blueprint && imageContainerRef.current) {
94
+ const { clientWidth, clientHeight } = imageContainerRef.current;
95
+ setViewBox({ width: clientWidth, height: clientHeight });
96
+ setScale({
97
+ x: clientWidth / blueprint.canvas.width,
98
+ y: clientHeight / blueprint.canvas.height
99
+ });
100
+ }
101
+ };
102
+ handleResize();
103
+ window.addEventListener('resize', handleResize);
104
+ return () => window.removeEventListener('resize', handleResize);
105
+ }, [image, blueprint]);
106
+
107
+
108
+ const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
109
+ const file = event.target.files?.[0];
110
+ if (file) {
111
+ if (file.size > 4 * 1024 * 1024) {
112
+ setError("File size exceeds 4MB. Please upload a smaller image.");
113
+ return;
114
+ }
115
+ const reader = new FileReader();
116
+ reader.onloadend = () => {
117
+ const base64String = (reader.result as string).split(',')[1];
118
+ setImage({ preview: reader.result as string, base64: base64String });
119
+ setError('');
120
+ setBlueprint(null);
121
+ setStatus(AppStatus.IDLE);
122
+ };
123
+ reader.onerror = () => setError("Failed to read the file.");
124
+ reader.readAsDataURL(file);
125
+ }
126
+ };
127
+
128
+ const handleGenerate = async () => {
129
+ if (!image) {
130
+ setError("Please upload an image first.");
131
+ return;
132
+ }
133
+
134
+ setStatus(AppStatus.ANALYZING);
135
+ setError('');
136
+ setBlueprint(null);
137
+
138
+ try {
139
+ const result = await generateNebariBlueprint(image.base64);
140
+ if (result) {
141
+ setBlueprint(result);
142
+ setStatus(AppStatus.SUCCESS);
143
+ } else {
144
+ throw new Error('Failed to generate nebari guide. The AI may be busy or the image unclear. Please try again.');
145
+ }
146
+ } catch (e: any) {
147
+ setError(e.message);
148
+ setStatus(AppStatus.ERROR);
149
+ }
150
+ };
151
+
152
+ return (
153
+ <div className="space-y-8 max-w-7xl mx-auto">
154
+ <header className="text-center">
155
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
156
+ <RootsIcon className="w-8 h-8 text-orange-800" />
157
+ Nebari Developer
158
+ </h2>
159
+ <p className="mt-4 text-lg leading-8 text-stone-600">
160
+ Get an AI-generated plan to develop the perfect surface roots (Nebari) for your bonsai.
161
+ </p>
162
+ </header>
163
+
164
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
165
+ {/* Left Column: Controls */}
166
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-4 lg:col-span-1">
167
+ <div>
168
+ <label className="block text-sm font-medium text-stone-900">1. Upload Photo of Tree Base</label>
169
+ <div onClick={() => fileInputRef.current?.click()} className="mt-1 flex justify-center p-4 rounded-lg border-2 border-dashed border-stone-300 hover:border-orange-600 transition-colors cursor-pointer">
170
+ <div className="text-center">
171
+ {image ? <p className="text-green-700 font-semibold">Image Loaded!</p> : <UploadCloudIcon className="mx-auto h-10 w-10 text-stone-400" />}
172
+ <p className="mt-1 text-sm text-stone-600">{image ? 'Click to change image' : 'Click to upload'}</p>
173
+ </div>
174
+ <input ref={fileInputRef} type="file" className="sr-only" onChange={handleFileChange} accept="image/png, image/jpeg" />
175
+ </div>
176
+ </div>
177
+
178
+ <button onClick={handleGenerate} disabled={status === AppStatus.ANALYZING || !image || !aiConfigured} className="w-full flex items-center justify-center gap-2 rounded-md bg-orange-800 px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-orange-700 disabled:bg-stone-400 disabled:cursor-not-allowed">
179
+ <SparklesIcon className="w-5 h-5"/> {status === AppStatus.ANALYZING ? 'Developing Plan...' : '2. Generate Nebari Plan'}
180
+ </button>
181
+ {error && <p className="text-sm text-red-600 mt-2 bg-red-50 p-3 rounded-md">{error}</p>}
182
+ {!aiConfigured && (
183
+ <div className="mt-4 p-3 bg-yellow-50 text-yellow-800 rounded-lg border border-yellow-200 text-center">
184
+ <p className="text-sm">
185
+ Please set your Gemini API key in the{' '}
186
+ <button onClick={() => setActiveView('settings')} className="font-bold underline hover:text-yellow-900">
187
+ Settings page
188
+ </button>
189
+ {' '}to enable this feature.
190
+ </p>
191
+ </div>
192
+ )}
193
+ </div>
194
+
195
+ {/* Right Column: Canvas */}
196
+ <div className="bg-white p-4 rounded-xl shadow-lg border border-stone-200 lg:col-span-2">
197
+ <div ref={imageContainerRef} className="relative w-full aspect-w-4 aspect-h-3 bg-stone-100 rounded-lg overflow-hidden">
198
+ {image ? (
199
+ <>
200
+ <img src={image.preview} alt="Bonsai Nebari" className="w-full h-full object-contain" />
201
+ {blueprint && (
202
+ <svg
203
+ className="absolute top-0 left-0 w-full h-full"
204
+ viewBox={`0 0 ${viewBox.width} ${viewBox.height}`}
205
+ xmlns="http://www.w3.org/2000/svg"
206
+ >
207
+ <defs>
208
+ <marker id="arrow-head-blue" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
209
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#3b82f6" />
210
+ </marker>
211
+ <marker id="arrow-head-orange" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
212
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#f97316" />
213
+ </marker>
214
+ </defs>
215
+ {blueprint.annotations.map((anno, i) => (
216
+ <SvgAnnotation key={i} annotation={anno} scale={scale} />
217
+ ))}
218
+ </svg>
219
+ )}
220
+ </>
221
+ ) : (
222
+ <div className="flex items-center justify-center h-full">
223
+ <p className="text-stone-500">Your image and root plan will appear here</p>
224
+ </div>
225
+ )}
226
+ {status === AppStatus.ANALYZING && <div className="absolute inset-0 flex items-center justify-center bg-white/75"><Spinner text="Yuki is inspecting the roots..." /></div>}
227
+ </div>
228
+ {blueprint && status === AppStatus.SUCCESS && (
229
+ <div className="mt-4 p-4 bg-orange-50 border-l-4 border-orange-500 rounded-r-lg">
230
+ <h4 className="font-bold text-orange-800">Yuki's Nebari Strategy</h4>
231
+ <p className="text-sm text-orange-700 mt-1">{blueprint.summary}</p>
232
+ </div>
233
+ )}
234
+ </div>
235
+ </div>
236
+ </div>
237
+ );
238
+ };
239
+
240
+ export default NebariDeveloperView;
views/PaywallView.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ // ====================================================================================
4
+ // NOTE: This component is no longer used in the application.
5
+ // The app now uses a secret-based login system. See LoginView.tsx.
6
+ // This file is kept to avoid breaking imports but its content is non-functional.
7
+ // ====================================================================================
8
+
9
+ const PaywallView: React.FC = () => {
10
+ return (
11
+ <div className="flex items-center justify-center h-screen bg-stone-100">
12
+ <div className="text-center p-8">
13
+ <h1 className="text-2xl font-bold">Paywall Deprecated</h1>
14
+ <p>This view is currently not in use.</p>
15
+ </div>
16
+ </div>
17
+ );
18
+ };
19
+
20
+ export default PaywallView;
views/PestLibraryView.tsx ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useCallback } from 'react';
2
+ import { BugIcon, SparklesIcon, AlertTriangleIcon } from '../components/icons';
3
+ import Spinner from '../components/Spinner';
4
+ import { generatePestLibrary, isAIConfigured } from '../services/geminiService';
5
+ import type { PestLibraryEntry, View } from '../types';
6
+ import { AppStatus } from '../types';
7
+
8
+ interface PestLibraryViewProps {
9
+ setActiveView: (view: View) => void;
10
+ }
11
+
12
+ const PestLibraryView: React.FC<PestLibraryViewProps> = ({ setActiveView }) => {
13
+ const [status, setStatus] = useState<AppStatus>(AppStatus.IDLE);
14
+ const [location, setLocation] = useState<string>('');
15
+ const [pestData, setPestData] = useState<PestLibraryEntry[] | null>(null);
16
+ const [error, setError] = useState<string>('');
17
+ const aiConfigured = isAIConfigured();
18
+
19
+ const handleGenerate = useCallback(async () => {
20
+ if (!location.trim()) {
21
+ setError('Please enter your city or region.');
22
+ return;
23
+ }
24
+ setStatus(AppStatus.ANALYZING);
25
+ setError('');
26
+ setPestData(null);
27
+
28
+ try {
29
+ const result = await generatePestLibrary(location);
30
+ if (result) {
31
+ setPestData(result);
32
+ setStatus(AppStatus.SUCCESS);
33
+ } else {
34
+ throw new Error('Failed to generate the pest library. The AI may be busy. Please try again.');
35
+ }
36
+ } catch (e: any) {
37
+ setError(e.message);
38
+ setStatus(AppStatus.ERROR);
39
+ }
40
+ }, [location]);
41
+
42
+ return (
43
+ <div className="space-y-8 max-w-4xl mx-auto">
44
+ <header className="text-center">
45
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
46
+ <BugIcon className="w-8 h-8 text-red-600" />
47
+ Regional Pest Library
48
+ </h2>
49
+ <p className="mt-4 text-lg leading-8 text-stone-600">
50
+ Discover common pests and diseases for bonsai in your area to stay one step ahead.
51
+ </p>
52
+ </header>
53
+
54
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200">
55
+ <div className="flex flex-col sm:flex-row gap-4">
56
+ <input
57
+ type="text"
58
+ value={location}
59
+ onChange={(e) => setLocation(e.target.value)}
60
+ 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-green-600 sm:text-sm sm:leading-6"
61
+ placeholder="e.g., Portland, Oregon"
62
+ disabled={status === AppStatus.ANALYZING}
63
+ />
64
+ <button
65
+ onClick={handleGenerate}
66
+ disabled={status === AppStatus.ANALYZING || !aiConfigured}
67
+ className="flex items-center justify-center gap-2 w-full sm:w-auto rounded-md bg-green-700 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-green-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-700 disabled:bg-stone-400 disabled:cursor-not-allowed"
68
+ >
69
+ <SparklesIcon className="w-5 h-5" />
70
+ {status === AppStatus.ANALYZING ? 'Generating...' : 'Generate Library'}
71
+ </button>
72
+ </div>
73
+ {error && <p className="text-sm text-red-600 mt-2">{error}</p>}
74
+ {!aiConfigured && (
75
+ <div className="mt-4 p-3 bg-yellow-50 text-yellow-800 rounded-lg border border-yellow-200 text-center">
76
+ <p className="text-sm">
77
+ AI features are disabled. Please set your Gemini API key in the{' '}
78
+ <button onClick={() => setActiveView('settings')} className="font-bold underline hover:text-yellow-900">
79
+ Settings page
80
+ </button>.
81
+ </p>
82
+ </div>
83
+ )}
84
+ </div>
85
+
86
+ {status === AppStatus.ANALYZING && <Spinner text="Yuki is consulting the archives..." />}
87
+ {status === AppStatus.SUCCESS && pestData && (
88
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-4">
89
+ <h3 className="text-xl font-bold text-stone-800">Pest & Disease Guide for {location}</h3>
90
+ {pestData.map((pest, i) => (
91
+ <details key={i} className="p-4 bg-stone-50 rounded-lg border border-stone-200 group">
92
+ <summary className="font-semibold text-stone-800 cursor-pointer flex justify-between items-center">
93
+ {pest.name} ({pest.type})
94
+ <span className="text-xs text-stone-500 group-open:hidden">Show Details</span>
95
+ <span className="text-xs text-stone-500 hidden group-open:inline">Hide Details</span>
96
+ </summary>
97
+ <div className="mt-4 space-y-3 text-sm text-stone-700">
98
+ <p>{pest.description}</p>
99
+ <div>
100
+ <strong className="font-medium text-stone-800">Symptoms:</strong>
101
+ <ul className="list-disc list-inside ml-2">
102
+ {pest.symptoms.map((s, idx) => <li key={idx}>{s}</li>)}
103
+ </ul>
104
+ </div>
105
+ <div>
106
+ <strong className="font-medium text-stone-800">Organic Treatment:</strong>
107
+ <p>{pest.treatment.organic}</p>
108
+ </div>
109
+ <div>
110
+ <strong className="font-medium text-stone-800">Chemical Treatment:</strong>
111
+ <p>{pest.treatment.chemical}</p>
112
+ </div>
113
+ </div>
114
+ </details>
115
+ ))}
116
+ </div>
117
+ )}
118
+ {status === AppStatus.ERROR && (
119
+ <div className="text-center p-8 bg-white rounded-lg shadow-lg border border-red-200 max-w-md mx-auto">
120
+ <h3 className="text-xl font-semibold text-red-700">An Error Occurred</h3>
121
+ <p className="text-stone-600 mt-2">{error}</p>
122
+ </div>
123
+ )}
124
+ </div>
125
+ );
126
+ };
127
+
128
+ export default PestLibraryView;
views/PotCalculatorView.tsx ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useMemo } from 'react';
2
+ import { PotRulerIcon } from '../components/icons';
3
+
4
+ type Units = 'cm' | 'in';
5
+ type BonsaiStyle = 'Upright' | 'Cascade' | 'Forest' | 'Literati';
6
+
7
+ const PotCalculatorView: React.FC = () => {
8
+ const [units, setUnits] = useState<Units>('cm');
9
+ const [height, setHeight] = useState(30);
10
+ const [trunkDiameter, setTrunkDiameter] = useState(3);
11
+ const [style, setStyle] = useState<BonsaiStyle>('Upright');
12
+
13
+ const conversionFactor = units === 'in' ? 2.54 : 1;
14
+
15
+ const results = useMemo(() => {
16
+ let length, width, depth, rationale;
17
+ const h = height;
18
+ const d = trunkDiameter;
19
+
20
+ switch (style) {
21
+ case 'Cascade':
22
+ length = h * 0.5;
23
+ depth = h * 0.6;
24
+ width = length;
25
+ rationale = "Cascade pots are tall and often square or hexagonal to visually balance the downward-flowing trunk. The depth and stability are crucial.";
26
+ break;
27
+ case 'Forest':
28
+ length = h * 1.5;
29
+ depth = d * 0.75;
30
+ width = length * 0.6;
31
+ rationale = "Forest plantings require wide, very shallow oval or rectangular trays to create a sense of a landscape and accommodate many root systems.";
32
+ break;
33
+ case 'Literati':
34
+ length = d * 3;
35
+ depth = d * 1.5;
36
+ width = length;
37
+ rationale = "Literati pots are typically small, simple, and often round or unusually shaped. They are understated to emphasize the elegant, sparse trunk line.";
38
+ break;
39
+ case 'Upright':
40
+ default:
41
+ length = h * 0.66;
42
+ depth = d;
43
+ width = length * 0.8;
44
+ rationale = "For upright styles, the pot length is typically 2/3 of the tree's height. The pot's depth should be equal to the trunk's diameter to provide visual stability.";
45
+ break;
46
+ }
47
+
48
+ const format = (val: number) => {
49
+ const converted = val / conversionFactor;
50
+ return converted.toFixed(1);
51
+ }
52
+
53
+ return {
54
+ length: format(length),
55
+ width: format(width),
56
+ depth: format(depth),
57
+ rationale,
58
+ };
59
+ }, [height, trunkDiameter, style, units, conversionFactor]);
60
+
61
+ return (
62
+ <div className="space-y-8 max-w-4xl mx-auto">
63
+ <header className="text-center">
64
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
65
+ <PotRulerIcon className="w-8 h-8 text-amber-800" />
66
+ Pot Ratio Calculator
67
+ </h2>
68
+ <p className="mt-4 text-lg leading-8 text-stone-600">
69
+ Find the perfect pot size for your bonsai based on traditional aesthetic guidelines.
70
+ </p>
71
+ </header>
72
+
73
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
74
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-6">
75
+ <div>
76
+ <div className="flex justify-between items-center mb-2">
77
+ <label htmlFor="units" className="font-semibold text-stone-800">Units</label>
78
+ <div className="flex gap-1 bg-stone-100 p-1 rounded-lg">
79
+ <button onClick={() => setUnits('cm')} className={`px-3 py-1 text-sm font-medium rounded-md ${units === 'cm' ? 'bg-white shadow' : ''}`}>cm</button>
80
+ <button onClick={() => setUnits('in')} className={`px-3 py-1 text-sm font-medium rounded-md ${units === 'in' ? 'bg-white shadow' : ''}`}>in</button>
81
+ </div>
82
+ </div>
83
+ </div>
84
+
85
+ <div>
86
+ <label htmlFor="height" className="font-semibold text-stone-800">Tree Height ({units})</label>
87
+ <div className="flex items-center gap-4 mt-2">
88
+ <input type="range" id="height" min="10" max="200" value={height} onChange={e => setHeight(Number(e.target.value))} className="w-full h-2 bg-stone-200 rounded-lg appearance-none cursor-pointer accent-amber-700" />
89
+ <input type="number" value={(height / conversionFactor).toFixed(1)} onChange={e => setHeight(Number(e.target.value) * conversionFactor)} className="w-20 p-2 border rounded-md" />
90
+ </div>
91
+ </div>
92
+
93
+ <div>
94
+ <label htmlFor="trunkDiameter" className="font-semibold text-stone-800">Trunk Diameter ({units})</label>
95
+ <div className="flex items-center gap-4 mt-2">
96
+ <input type="range" id="trunkDiameter" min="1" max="20" step="0.5" value={trunkDiameter} onChange={e => setTrunkDiameter(Number(e.target.value))} className="w-full h-2 bg-stone-200 rounded-lg appearance-none cursor-pointer accent-amber-700" />
97
+ <input type="number" value={(trunkDiameter / conversionFactor).toFixed(1)} onChange={e => setTrunkDiameter(Number(e.target.value) * conversionFactor)} className="w-20 p-2 border rounded-md" />
98
+ </div>
99
+ </div>
100
+
101
+ <div>
102
+ <label htmlFor="style" className="font-semibold text-stone-800">Bonsai Style</label>
103
+ <select id="style" value={style} onChange={e => setStyle(e.target.value as BonsaiStyle)} className="w-full mt-2 p-2 border rounded-md bg-white">
104
+ <option value="Upright">Formal/Informal Upright</option>
105
+ <option value="Cascade">Cascade/Semi-Cascade</option>
106
+ <option value="Forest">Forest/Group Planting</option>
107
+ <option value="Literati">Literati (Bunjin)</option>
108
+ </select>
109
+ </div>
110
+ </div>
111
+
112
+ <div className="bg-white p-6 rounded-xl shadow-lg border-2 border-amber-600 space-y-4">
113
+ <h3 className="text-xl font-bold text-center text-stone-900">Recommended Pot Size</h3>
114
+ <div className="flex justify-around text-center">
115
+ <div>
116
+ <p className="text-3xl font-bold text-amber-800">{results.length}</p>
117
+ <p className="text-sm text-stone-600">Length ({units})</p>
118
+ </div>
119
+ <div>
120
+ <p className="text-3xl font-bold text-amber-800">{results.width}</p>
121
+ <p className="text-sm text-stone-600">Width ({units})</p>
122
+ </div>
123
+ <div>
124
+ <p className="text-3xl font-bold text-amber-800">{results.depth}</p>
125
+ <p className="text-sm text-stone-600">Depth ({units})</p>
126
+ </div>
127
+ </div>
128
+ <div className="mt-4 pt-4 border-t-2 border-dashed">
129
+ <h4 className="font-semibold text-stone-800">Rationale</h4>
130
+ <p className="text-sm text-stone-700 mt-1">{results.rationale}</p>
131
+ </div>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ );
136
+ };
137
+
138
+ export default PotCalculatorView;
views/SeasonalGuideView.tsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useCallback } from 'react';
2
+ import { LeafIcon, SparklesIcon, SunIcon, WindIcon, SnowflakeIcon, SunriseIcon, AlertTriangleIcon } from '../components/icons';
3
+ import Spinner from '../components/Spinner';
4
+ import { generateSeasonalGuide, isAIConfigured } from '../services/geminiService';
5
+ import type { SeasonalGuide, View } from '../types';
6
+ import { AppStatus } from '../types';
7
+
8
+ const seasonIcons: { [key: string]: React.FC<React.SVGProps<SVGSVGElement>> } = {
9
+ Spring: SunriseIcon,
10
+ Summer: SunIcon,
11
+ Autumn: WindIcon,
12
+ Winter: SnowflakeIcon,
13
+ };
14
+
15
+ interface SeasonalGuideViewProps {
16
+ setActiveView: (view: View) => void;
17
+ }
18
+
19
+ const SeasonalGuideView: React.FC<SeasonalGuideViewProps> = ({ setActiveView }) => {
20
+ const [status, setStatus] = useState<AppStatus>(AppStatus.IDLE);
21
+ const [species, setSpecies] = useState<string>('');
22
+ const [location, setLocation] = useState<string>('');
23
+ const [guideData, setGuideData] = useState<SeasonalGuide[] | null>(null);
24
+ const [error, setError] = useState<string>('');
25
+ const aiConfigured = isAIConfigured();
26
+
27
+ const handleGenerate = useCallback(async () => {
28
+ if (!species.trim()) {
29
+ setError('Please enter a bonsai species.');
30
+ return;
31
+ }
32
+ if (!location.trim()) {
33
+ setError('Please enter your city or region.');
34
+ return;
35
+ }
36
+ setStatus(AppStatus.ANALYZING);
37
+ setError('');
38
+ setGuideData(null);
39
+
40
+ try {
41
+ const result = await generateSeasonalGuide(species, location);
42
+ if (result) {
43
+ setGuideData(result);
44
+ setStatus(AppStatus.SUCCESS);
45
+ } else {
46
+ throw new Error('Failed to generate the seasonal guide. The AI may be busy. Please try again.');
47
+ }
48
+ } catch(e: any) {
49
+ setError(e.message);
50
+ setStatus(AppStatus.ERROR);
51
+ }
52
+ }, [species, location]);
53
+
54
+ return (
55
+ <div className="space-y-8 max-w-4xl mx-auto">
56
+ <header className="text-center">
57
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
58
+ <LeafIcon className="w-8 h-8 text-green-600" />
59
+ Seasonal Care Guides
60
+ </h2>
61
+ <p className="mt-4 text-lg leading-8 text-stone-600">
62
+ Get a year-round plan for any bonsai species, tailored to your local climate.
63
+ </p>
64
+ </header>
65
+
66
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-4">
67
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
68
+ <input
69
+ type="text"
70
+ value={species}
71
+ onChange={(e) => setSpecies(e.target.value)}
72
+ 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-green-600"
73
+ placeholder="e.g., Ficus Retusa"
74
+ disabled={status === AppStatus.ANALYZING}
75
+ />
76
+ <input
77
+ type="text"
78
+ value={location}
79
+ onChange={(e) => setLocation(e.target.value)}
80
+ 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-green-600"
81
+ placeholder="e.g., Miami, Florida"
82
+ disabled={status === AppStatus.ANALYZING}
83
+ />
84
+ </div>
85
+ <button
86
+ onClick={handleGenerate}
87
+ disabled={status === AppStatus.ANALYZING || !aiConfigured}
88
+ className="w-full flex items-center justify-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 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-700 disabled:bg-stone-400 disabled:cursor-not-allowed"
89
+ >
90
+ <SparklesIcon className="w-5 h-5" />
91
+ {status === AppStatus.ANALYZING ? 'Generating...' : 'Generate Guide'}
92
+ </button>
93
+ {error && <p className="text-sm text-red-600 mt-2">{error}</p>}
94
+ {!aiConfigured && (
95
+ <div className="mt-4 p-3 bg-yellow-50 text-yellow-800 rounded-lg border border-yellow-200 text-center">
96
+ <p className="text-sm">
97
+ AI features are disabled. Please set your Gemini API key in the{' '}
98
+ <button onClick={() => setActiveView('settings')} className="font-bold underline hover:text-yellow-900">
99
+ Settings page
100
+ </button>.
101
+ </p>
102
+ </div>
103
+ )}
104
+ </div>
105
+
106
+ {status === AppStatus.ANALYZING && <Spinner text="Yuki is reading the almanac..." />}
107
+ {status === AppStatus.SUCCESS && guideData && (
108
+ <div className="space-y-6">
109
+ <h3 className="text-2xl font-bold text-stone-800 text-center">Seasonal Guide for a {species} in {location}</h3>
110
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
111
+ {guideData.sort((a,b) => ['Spring', 'Summer', 'Autumn', 'Winter'].indexOf(a.season) - ['Spring', 'Summer', 'Autumn', 'Winter'].indexOf(b.season)).map(season => {
112
+ const Icon = seasonIcons[season.season] || LeafIcon;
113
+ return (
114
+ <div key={season.season} className="bg-white rounded-xl shadow-md border border-stone-200 p-6">
115
+ <div className="flex items-center gap-3 mb-4">
116
+ <Icon className="w-7 h-7 text-green-700" />
117
+ <h3 className="text-xl font-semibold text-stone-800">{season.season}</h3>
118
+ </div>
119
+ <div className="space-y-3 text-stone-600">
120
+ <p className="italic text-stone-600 mb-4">{season.summary}</p>
121
+ <ul className="space-y-2">
122
+ {season.tasks.map(task => (
123
+ <li key={task.task} className="flex items-center justify-between text-sm">
124
+ <span>{task.task}</span>
125
+ <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>
126
+ </li>
127
+ ))}
128
+ </ul>
129
+ </div>
130
+ </div>
131
+ )
132
+ })}
133
+ </div>
134
+ </div>
135
+ )}
136
+ </div>
137
+ );
138
+ };
139
+
140
+ export default SeasonalGuideView;
views/SettingsView.tsx ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useContext, useRef, useState, useEffect } from 'react';
2
+ import { SettingsIcon, AlertTriangleIcon, Trash2Icon, KeyIcon } from '../components/icons';
3
+ import { AuthContext } from '../context/AuthContext';
4
+ import { useLocalStorage } from '../hooks/useLocalStorage';
5
+ import type { BonsaiTree, UserTool } from '../types';
6
+ import { reinitializeAI } from '../services/geminiService';
7
+
8
+ const SettingsView: React.FC = () => {
9
+ const { logout, updateCredentials } = useContext(AuthContext);
10
+ const [, setTrees] = useLocalStorage<BonsaiTree[]>('bonsai-diary-trees', []);
11
+ const [, setToolkit] = useLocalStorage<UserTool[]>('user-toolkit', []);
12
+ const importFileRef = useRef<HTMLInputElement>(null);
13
+
14
+ const [apiKey, setApiKey] = useState('');
15
+ const [saveStatus, setSaveStatus] = useState<'idle' | 'saved'>('idle');
16
+
17
+ // State for credentials change
18
+ const [currentPassword, setCurrentPassword] = useState('');
19
+ const [newLoginName, setNewLoginName] = useState('');
20
+ const [newPassword, setNewPassword] = useState('');
21
+ const [credentialStatus, setCredentialStatus] = useState<{ type: 'idle' | 'success' | 'error', message: string }>({ type: 'idle', message: '' });
22
+ const [isUpdatingCreds, setIsUpdatingCreds] = useState(false);
23
+
24
+ useEffect(() => {
25
+ const storedKey = window.localStorage.getItem('gemini-api-key') || '';
26
+ setApiKey(storedKey);
27
+ }, []);
28
+
29
+ const handleSaveKey = () => {
30
+ window.localStorage.setItem('gemini-api-key', apiKey);
31
+ reinitializeAI();
32
+ setSaveStatus('saved');
33
+ setTimeout(() => setSaveStatus('idle'), 2000);
34
+ };
35
+
36
+ const handleClearKey = () => {
37
+ if (window.confirm('Are you sure you want to clear your API key?')) {
38
+ window.localStorage.removeItem('gemini-api-key');
39
+ setApiKey('');
40
+ reinitializeAI();
41
+ }
42
+ };
43
+
44
+ const handleCredentialChange = (e: React.FormEvent) => {
45
+ e.preventDefault();
46
+ setIsUpdatingCreds(true);
47
+ const result = updateCredentials(currentPassword, newLoginName, newPassword);
48
+ if (result.success) {
49
+ setCredentialStatus({ type: 'success', message: "Credentials updated! You will be logged out to sign in again." });
50
+ setTimeout(() => {
51
+ logout();
52
+ }, 2000);
53
+ } else {
54
+ setCredentialStatus({ type: 'error', message: result.message });
55
+ setTimeout(() => setCredentialStatus({ type: 'idle', message: '' }), 3000);
56
+ setIsUpdatingCreds(false);
57
+ }
58
+ };
59
+
60
+ const handleExport = () => {
61
+ const treeKey = `yuki-app-bonsai-diary-trees`;
62
+ const toolKey = `yuki-app-user-toolkit`;
63
+
64
+ const data = {
65
+ trees: JSON.parse(window.localStorage.getItem(treeKey) || '[]'),
66
+ toolkit: JSON.parse(window.localStorage.getItem(toolKey) || '[]'),
67
+ };
68
+
69
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
70
+ const url = URL.createObjectURL(blob);
71
+ const a = document.createElement('a');
72
+ a.href = url;
73
+ a.download = `yuki-bonsai-backup-${new Date().toISOString().split('T')[0]}.json`;
74
+ document.body.appendChild(a);
75
+ a.click();
76
+ document.body.removeChild(a);
77
+ URL.revokeObjectURL(url);
78
+ };
79
+
80
+ const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
81
+ const file = event.target.files?.[0];
82
+ if (!file) return;
83
+
84
+ const reader = new FileReader();
85
+ reader.onload = (e) => {
86
+ try {
87
+ const text = e.target?.result;
88
+ if (typeof text !== 'string') throw new Error("File is not readable");
89
+ const data = JSON.parse(text);
90
+
91
+ if (Array.isArray(data.trees) && Array.isArray(data.toolkit)) {
92
+ if(window.confirm("This will overwrite your current data. Are you sure you want to proceed?")) {
93
+ setTrees(data.trees);
94
+ setToolkit(data.toolkit);
95
+ alert("Data imported successfully!");
96
+ }
97
+ } else {
98
+ throw new Error("Invalid file format.");
99
+ }
100
+ } catch (err) {
101
+ alert(`Failed to import data: ${err instanceof Error ? err.message : "Unknown error"}`);
102
+ }
103
+ };
104
+ reader.readAsText(file);
105
+ event.target.value = ''; // Reset file input
106
+ };
107
+
108
+ const handleDelete = () => {
109
+ if (window.confirm("DANGER: Are you absolutely sure you want to delete all your trees and toolkit data? This action cannot be undone.")) {
110
+ if (window.confirm("FINAL WARNING: This is your last chance. All data will be permanently deleted. Continue?")) {
111
+ setTrees([]);
112
+ setToolkit([]);
113
+ alert("All your data has been deleted.");
114
+ logout();
115
+ }
116
+ }
117
+ };
118
+
119
+
120
+ return (
121
+ <div className="space-y-8 max-w-2xl mx-auto">
122
+ <header className="text-center">
123
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
124
+ <SettingsIcon className="w-8 h-8 text-stone-600" />
125
+ Settings
126
+ </h2>
127
+ <p className="mt-4 text-lg leading-8 text-stone-600">
128
+ Manage your application data and preferences.
129
+ </p>
130
+ </header>
131
+
132
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-4">
133
+ <h3 className="text-xl font-semibold text-stone-800 flex items-center gap-2"><KeyIcon className="w-5 h-5"/> Change Login Credentials</h3>
134
+ <p className="text-sm text-stone-600">
135
+ Update your login name and secret password. If you haven't set custom credentials yet, the "Current Secret Password" is the one you originally received. After updating, you will be logged out.
136
+ </p>
137
+ <form onSubmit={handleCredentialChange} className="space-y-3">
138
+ <div>
139
+ <label className="text-sm font-medium text-stone-700">Current Secret Password</label>
140
+ <input
141
+ type="password"
142
+ value={currentPassword}
143
+ onChange={(e) => setCurrentPassword(e.target.value)}
144
+ className="mt-1 block w-full rounded-md border-0 py-2 px-3 text-stone-900 shadow-sm ring-1 ring-inset ring-stone-300 focus:ring-2 focus:ring-inset focus:ring-green-600"
145
+ required
146
+ />
147
+ </div>
148
+ <div>
149
+ <label className="text-sm font-medium text-stone-700">New Login Name</label>
150
+ <input
151
+ type="text"
152
+ value={newLoginName}
153
+ onChange={(e) => setNewLoginName(e.target.value)}
154
+ className="mt-1 block w-full rounded-md border-0 py-2 px-3 text-stone-900 shadow-sm ring-1 ring-inset ring-stone-300 focus:ring-2 focus:ring-inset focus:ring-green-600"
155
+ placeholder="e.g., my-bonsai-corner"
156
+ required
157
+ />
158
+ </div>
159
+ <div>
160
+ <label className="text-sm font-medium text-stone-700">New Secret Password</label>
161
+ <input
162
+ type="password"
163
+ value={newPassword}
164
+ onChange={(e) => setNewPassword(e.target.value)}
165
+ className="mt-1 block w-full rounded-md border-0 py-2 px-3 text-stone-900 shadow-sm ring-1 ring-inset ring-stone-300 focus:ring-2 focus:ring-inset focus:ring-green-600"
166
+ placeholder="Enter a strong new password"
167
+ required
168
+ />
169
+ </div>
170
+ <button type="submit" disabled={isUpdatingCreds} className="w-full bg-green-700 text-white font-semibold py-2 px-4 rounded-lg hover:bg-green-600 disabled:bg-stone-400">
171
+ {isUpdatingCreds ? 'Updating...' : 'Update Credentials & Log Out'}
172
+ </button>
173
+ {credentialStatus.type === 'success' && <p className="text-sm text-center text-green-600">{credentialStatus.message}</p>}
174
+ {credentialStatus.type === 'error' && <p className="text-sm text-center text-red-600">{credentialStatus.message}</p>}
175
+ </form>
176
+ </div>
177
+
178
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-4">
179
+ <h3 className="text-xl font-semibold text-stone-800">Gemini API Key</h3>
180
+ <p className="text-sm text-stone-600">
181
+ To use the AI features, you need a Google Gemini API key. You can generate a free key from{' '}
182
+ <a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener noreferrer" className="font-semibold text-green-700 hover:underline">
183
+ Google AI Studio
184
+ </a>.
185
+ </p>
186
+ <div className="flex items-center gap-2">
187
+ <input
188
+ type="password"
189
+ value={apiKey}
190
+ onChange={(e) => setApiKey(e.target.value)}
191
+ 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-green-600 sm:text-sm sm:leading-6"
192
+ placeholder="Enter your Gemini API key"
193
+ />
194
+ <button onClick={handleSaveKey} className="bg-green-700 text-white font-semibold py-2 px-4 rounded-lg hover:bg-green-600 whitespace-nowrap">
195
+ {saveStatus === 'saved' ? 'Saved!' : 'Save Key'}
196
+ </button>
197
+ <button onClick={handleClearKey} title="Clear API Key" className="bg-stone-200 text-stone-700 p-2 rounded-lg hover:bg-stone-300">
198
+ <Trash2Icon className="w-5 h-5"/>
199
+ </button>
200
+ </div>
201
+ </div>
202
+
203
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-4">
204
+ <h3 className="text-xl font-semibold text-stone-800">Data Management</h3>
205
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
206
+ <button onClick={handleExport} className="p-4 bg-blue-100 text-blue-700 font-semibold rounded-lg hover:bg-blue-200 transition-colors">
207
+ Export My Data
208
+ </button>
209
+ <button onClick={() => importFileRef.current?.click()} className="p-4 bg-green-100 text-green-700 font-semibold rounded-lg hover:bg-green-200 transition-colors">
210
+ Import Data
211
+ </button>
212
+ <input type="file" ref={importFileRef} onChange={handleImport} accept=".json" className="hidden" />
213
+ </div>
214
+ </div>
215
+
216
+ <div className="bg-red-50 p-6 rounded-xl shadow-lg border border-red-200 space-y-3">
217
+ <div className="flex items-center gap-3">
218
+ <AlertTriangleIcon className="w-8 h-8 text-red-600 flex-shrink-0" />
219
+ <div>
220
+ <h3 className="text-xl font-semibold text-red-800">Danger Zone</h3>
221
+ <p className="text-sm text-red-700">This action is irreversible. Please export your data first if you want to keep a backup.</p>
222
+ </div>
223
+ </div>
224
+ <button onClick={handleDelete} className="w-full p-4 bg-red-600 text-white font-bold rounded-lg hover:bg-red-700 transition-colors">
225
+ Delete All My Data & Log Out
226
+ </button>
227
+ </div>
228
+ </div>
229
+ );
230
+ };
231
+
232
+ export default SettingsView;
views/SoilAnalyzerView.tsx ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+ import React, { useState, useRef } from 'react';
4
+ import { FilterIcon, SparklesIcon, UploadCloudIcon, DropletIcon, AlertTriangleIcon } from '../components/icons';
5
+ import Spinner from '../components/Spinner';
6
+ import { analyzeSoilComposition, isAIConfigured } from '../services/geminiService';
7
+ import type { SoilAnalysis, View } from '../types';
8
+ import { AppStatus } from '../types';
9
+
10
+ const SoilAnalyzerView: React.FC<{ setActiveView: (view: View) => void }> = ({ setActiveView }) => {
11
+ const [status, setStatus] = useState<AppStatus>(AppStatus.IDLE);
12
+ const [image, setImage] = useState<{ preview: string; base64: string } | null>(null);
13
+ const [species, setSpecies] = useState<string>('');
14
+ const [location, setLocation] = useState<string>('');
15
+ const [result, setResult] = useState<SoilAnalysis | null>(null);
16
+ const [error, setError] = useState<string>('');
17
+ const fileInputRef = useRef<HTMLInputElement>(null);
18
+ const aiConfigured = isAIConfigured();
19
+
20
+ const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
21
+ const file = event.target.files?.[0];
22
+ if (file) {
23
+ if (file.size > 4 * 1024 * 1024) { // 4MB limit
24
+ setError("File size exceeds 4MB. Please upload a smaller image.");
25
+ return;
26
+ }
27
+ const reader = new FileReader();
28
+ reader.onloadend = () => {
29
+ const base64String = (reader.result as string).split(',')[1];
30
+ setImage({ preview: reader.result as string, base64: base64String });
31
+ setError('');
32
+ setStatus(AppStatus.IDLE);
33
+ setResult(null);
34
+ };
35
+ reader.onerror = () => setError("Failed to read the file.");
36
+ reader.readAsDataURL(file);
37
+ }
38
+ };
39
+
40
+ const handleAnalyze = async () => {
41
+ if (!image) {
42
+ setError("Please upload an image of your soil mix.");
43
+ return;
44
+ }
45
+ if (!species.trim()) {
46
+ setError("Please enter the target species.");
47
+ return;
48
+ }
49
+ if (!location.trim()) {
50
+ setError("Please enter your location.");
51
+ return;
52
+ }
53
+
54
+ setStatus(AppStatus.ANALYZING);
55
+ setError('');
56
+ setResult(null);
57
+
58
+ try {
59
+ const analysisResult = await analyzeSoilComposition(image.base64, species, location);
60
+ if (analysisResult) {
61
+ setResult(analysisResult);
62
+ setStatus(AppStatus.SUCCESS);
63
+ } else {
64
+ throw new Error("Could not analyze the soil. The AI may be busy or the image may not be clear enough. Please try again.");
65
+ }
66
+ } catch (e: any) {
67
+ setError(e.message);
68
+ setStatus(AppStatus.ERROR);
69
+ }
70
+ };
71
+
72
+ const renderResults = () => {
73
+ if (!result) return null;
74
+
75
+ const ratingColors = {
76
+ Poor: 'bg-red-500',
77
+ Average: 'bg-yellow-500',
78
+ Good: 'bg-blue-500',
79
+ Excellent: 'bg-green-500',
80
+ Low: 'bg-orange-500',
81
+ Medium: 'bg-yellow-500',
82
+ High: 'bg-blue-500'
83
+ };
84
+
85
+ return (
86
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-6">
87
+ <h3 className="text-xl font-bold text-center text-stone-800">Soil Analysis Results</h3>
88
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
89
+ <div>
90
+ <h4 className="font-semibold text-stone-800 mb-2">Estimated Composition</h4>
91
+ <div className="space-y-3">
92
+ {result.components.map(comp => (
93
+ <div key={comp.name}>
94
+ <div className="flex justify-between items-center mb-1">
95
+ <span className="font-medium text-stone-800">{comp.name}</span>
96
+ <span className="font-semibold text-amber-800">{comp.percentage}%</span>
97
+ </div>
98
+ <div className="w-full bg-stone-200 rounded-full h-2.5">
99
+ <div className="bg-amber-600 h-2.5 rounded-full" style={{ width: `${comp.percentage}%` }}></div>
100
+ </div>
101
+ </div>
102
+ ))}
103
+ </div>
104
+ </div>
105
+ <div className="space-y-4">
106
+ <h4 className="font-semibold text-stone-800 mb-2">Properties</h4>
107
+ <div className="flex items-center gap-3">
108
+ <strong className="w-32">Drainage:</strong>
109
+ <span className={`px-3 py-1 text-sm font-medium text-white rounded-full ${ratingColors[result.drainageRating]}`}>{result.drainageRating}</span>
110
+ </div>
111
+ <div className="flex items-center gap-3">
112
+ <strong className="w-32">Water Retention:</strong>
113
+ <span className={`px-3 py-1 text-sm font-medium text-white rounded-full ${ratingColors[result.waterRetention]}`}>{result.waterRetention}</span>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ <div className="mt-4 p-4 bg-stone-50 rounded-lg">
118
+ <h5 className="font-semibold text-stone-800">Suitability for {species}</h5>
119
+ <p className="text-sm text-stone-600 mt-1">{result.suitabilityAnalysis}</p>
120
+ </div>
121
+ <div className="mt-4 p-4 bg-green-50 rounded-lg">
122
+ <h5 className="font-semibold text-green-800">Improvement Suggestions</h5>
123
+ <p className="text-sm text-green-700 mt-1">{result.improvementSuggestions}</p>
124
+ </div>
125
+ <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">
126
+ Analyze Another Mix
127
+ </button>
128
+ </div>
129
+ );
130
+ }
131
+
132
+ return (
133
+ <div className="space-y-8 max-w-3xl mx-auto">
134
+ <header className="text-center">
135
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
136
+ <FilterIcon className="w-8 h-8 text-amber-700" />
137
+ Soil Analyzer
138
+ </h2>
139
+ <p className="mt-4 text-lg leading-8 text-stone-600">
140
+ Take a photo of your bonsai soil to get an AI-powered analysis of its composition and suitability.
141
+ </p>
142
+ </header>
143
+
144
+ {status !== AppStatus.SUCCESS && (
145
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-4">
146
+ <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">
147
+ <div className="text-center">
148
+ {image ? <img src={image.preview} alt="Soil preview" className="mx-auto h-40 w-auto rounded-md object-cover" /> : (
149
+ <>
150
+ <UploadCloudIcon className="mx-auto h-12 w-12 text-stone-400" />
151
+ <p className="mt-2 text-sm font-semibold text-amber-700">Upload a close-up photo of your soil</p>
152
+ <p className="text-xs text-stone-500">PNG, JPG up to 4MB</p>
153
+ </> )}
154
+ </div>
155
+ <input ref={fileInputRef} type="file" className="sr-only" onChange={handleFileChange} accept="image/png, image/jpeg" />
156
+ </div>
157
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
158
+ <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)" />
159
+ <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)" />
160
+ </div>
161
+ {error && <p className="text-sm text-red-600">{error}</p>}
162
+ <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">
163
+ <SparklesIcon className="w-5 h-5"/> {status === AppStatus.ANALYZING ? 'Analyzing...' : 'Analyze Soil'}
164
+ </button>
165
+ {!aiConfigured && (
166
+ <div className="mt-4 p-3 bg-yellow-50 text-yellow-800 rounded-lg border border-yellow-200 text-center">
167
+ <p className="text-sm">
168
+ Please set your Gemini API key in the{' '}
169
+ <button onClick={() => setActiveView('settings')} className="font-bold underline hover:text-yellow-900">
170
+ Settings page
171
+ </button>
172
+ {' '}to enable this feature.
173
+ </p>
174
+ </div>
175
+ )}
176
+ </div>
177
+ )}
178
+
179
+ {status === AppStatus.ANALYZING && <Spinner text="Yuki is sifting through the details..." />}
180
+ {status === AppStatus.SUCCESS && renderResults()}
181
+ {status === AppStatus.ERROR && <p className="text-center text-red-600 p-4 bg-red-50 rounded-md">{error}</p>}
182
+
183
+ </div>
184
+ );
185
+ };
186
+
187
+ export default SoilAnalyzerView;
views/SoilVolumeCalculatorView.tsx ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useMemo } from 'react';
3
+ import { ShovelIcon, PlusCircleIcon, Trash2Icon } from '../components/icons';
4
+
5
+ type PotShape = 'rectangular' | 'round';
6
+ type Units = 'cm' | 'in';
7
+
8
+ const SoilVolumeCalculatorView: React.FC = () => {
9
+ const [units, setUnits] = useState<Units>('in');
10
+ const [shape, setShape] = useState<PotShape>('rectangular');
11
+ const [dimensions, setDimensions] = useState({ length: 12, width: 8, depth: 4, diameter: 10 });
12
+ const [recipe, setRecipe] = useState([
13
+ { id: 1, name: 'Akadama', percentage: 40 },
14
+ { id: 2, name: 'Pumice', percentage: 30 },
15
+ { id: 3, name: 'Lava Rock', percentage: 30 },
16
+ ]);
17
+
18
+ const totalPercentage = useMemo(() => recipe.reduce((sum, item) => sum + item.percentage, 0), [recipe]);
19
+
20
+ const volumeCm3 = useMemo(() => {
21
+ const d = dimensions;
22
+ const toCm = units === 'in' ? 2.54 : 1;
23
+ if (shape === 'rectangular') {
24
+ return (d.length * toCm) * (d.width * toCm) * (d.depth * toCm);
25
+ } else { // round
26
+ const radius = (d.diameter * toCm) / 2;
27
+ return Math.PI * (radius * radius) * (d.depth * toCm);
28
+ }
29
+ }, [dimensions, shape, units]);
30
+
31
+ const handleRecipeChange = (id: number, field: 'name' | 'percentage', value: string | number) => {
32
+ setRecipe(prev => prev.map(item => item.id === id ? { ...item, [field]: value } : item));
33
+ };
34
+
35
+ const addComponent = () => {
36
+ setRecipe(prev => [...prev, { id: Date.now(), name: 'New Component', percentage: 0 }]);
37
+ };
38
+
39
+ const removeComponent = (id: number) => {
40
+ setRecipe(prev => prev.filter(item => item.id !== id));
41
+ };
42
+
43
+ const renderVolume = (volumeLiters: number) => (
44
+ <div className="text-sm text-stone-600">
45
+ <span>{volumeLiters.toFixed(2)} L</span> /&nbsp;
46
+ <span>{(volumeLiters * 1.05669).toFixed(2)} qts</span> /&nbsp;
47
+ <span>{(volumeLiters * 0.264172).toFixed(2)} gal</span>
48
+ </div>
49
+ );
50
+
51
+ return (
52
+ <div className="space-y-8 max-w-4xl mx-auto">
53
+ <header className="text-center">
54
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
55
+ <ShovelIcon className="w-8 h-8 text-orange-900" />
56
+ Soil Mix Calculator
57
+ </h2>
58
+ <p className="mt-4 text-lg leading-8 text-stone-600">
59
+ Mix the perfect amount of soil for your pot. No more waste or running out halfway through repotting.
60
+ </p>
61
+ </header>
62
+
63
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
64
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-6">
65
+ <div>
66
+ <h3 className="font-semibold text-stone-800 mb-2">1. Pot Dimensions</h3>
67
+ <div className="flex gap-4 mb-4">
68
+ <div className="flex-1">
69
+ <label className="text-sm font-medium">Shape</label>
70
+ <select value={shape} onChange={e => setShape(e.target.value as PotShape)} className="w-full mt-1 p-2 border rounded-md bg-white">
71
+ <option value="rectangular">Rectangular</option>
72
+ <option value="round">Round</option>
73
+ </select>
74
+ </div>
75
+ <div className="flex-1">
76
+ <label className="text-sm font-medium">Units</label>
77
+ <select value={units} onChange={e => setUnits(e.target.value as Units)} className="w-full mt-1 p-2 border rounded-md bg-white">
78
+ <option value="cm">Centimeters</option>
79
+ <option value="in">Inches</option>
80
+ </select>
81
+ </div>
82
+ </div>
83
+ <div className="grid grid-cols-2 gap-4">
84
+ {shape === 'rectangular' ? (
85
+ <>
86
+ <div><label>Length</label><input type="number" value={dimensions.length} onChange={e => setDimensions(d => ({...d, length: Number(e.target.value)}))} className="w-full mt-1 p-2 border rounded-md" /></div>
87
+ <div><label>Width</label><input type="number" value={dimensions.width} onChange={e => setDimensions(d => ({...d, width: Number(e.target.value)}))} className="w-full mt-1 p-2 border rounded-md" /></div>
88
+ </>
89
+ ) : (
90
+ <div><label>Diameter</label><input type="number" value={dimensions.diameter} onChange={e => setDimensions(d => ({...d, diameter: Number(e.target.value)}))} className="w-full mt-1 p-2 border rounded-md" /></div>
91
+ )}
92
+ <div><label>Depth</label><input type="number" value={dimensions.depth} onChange={e => setDimensions(d => ({...d, depth: Number(e.target.value)}))} className="w-full mt-1 p-2 border rounded-md" /></div>
93
+ </div>
94
+ </div>
95
+
96
+ <div>
97
+ <h3 className="font-semibold text-stone-800 mb-2">2. Soil Recipe</h3>
98
+ <div className="space-y-2">
99
+ {recipe.map(item => (
100
+ <div key={item.id} className="flex items-center gap-2">
101
+ <input type="text" value={item.name} onChange={e => handleRecipeChange(item.id, 'name', e.target.value)} className="w-full p-2 border rounded-md" />
102
+ <input type="number" value={item.percentage} onChange={e => handleRecipeChange(item.id, 'percentage', Number(e.target.value))} className="w-24 p-2 border rounded-md" />
103
+ <span className="font-bold text-stone-500">%</span>
104
+ <button onClick={() => removeComponent(item.id)} className="p-2 text-red-500 hover:bg-red-100 rounded-md"><Trash2Icon className="w-5 h-5"/></button>
105
+ </div>
106
+ ))}
107
+ </div>
108
+ <button onClick={addComponent} className="mt-2 flex items-center gap-2 text-sm font-semibold text-green-700 hover:text-green-600">
109
+ <PlusCircleIcon className="w-5 h-5"/> Add Component
110
+ </button>
111
+ <div className="mt-4 w-full bg-stone-200 rounded-full h-2.5">
112
+ <div className={`h-2.5 rounded-full ${totalPercentage > 100 ? 'bg-red-500' : 'bg-green-600'}`} style={{ width: `${Math.min(100, totalPercentage)}%` }}></div>
113
+ </div>
114
+ <p className={`text-center text-sm mt-1 font-bold ${totalPercentage !== 100 ? 'text-red-600 animate-pulse' : 'text-green-700'}`}>
115
+ Total: {totalPercentage}%
116
+ </p>
117
+ </div>
118
+ </div>
119
+
120
+ <div className="bg-white p-6 rounded-xl shadow-lg border-2 border-orange-800 space-y-4">
121
+ <h3 className="text-xl font-bold text-center text-stone-900">Component Volumes Needed</h3>
122
+ <div className="text-center">
123
+ <p className="text-stone-600">Total Pot Volume:</p>
124
+ <p className="font-bold text-lg text-orange-900">{renderVolume(volumeCm3 / 1000)}</p>
125
+ </div>
126
+ <div className="divide-y divide-stone-200 border-t pt-4">
127
+ {recipe.map(item => {
128
+ const componentVolumeL = (volumeCm3 / 1000) * (item.percentage / 100);
129
+ return (
130
+ <div key={item.id} className="py-3">
131
+ <div className="flex justify-between items-center font-bold">
132
+ <span className="text-stone-800">{item.name}</span>
133
+ <span className="text-orange-900">{item.percentage}%</span>
134
+ </div>
135
+ <div className="text-right">{renderVolume(componentVolumeL)}</div>
136
+ </div>
137
+ )
138
+ })}
139
+ </div>
140
+ {totalPercentage !== 100 && (
141
+ <p className="text-center text-red-600 text-sm font-semibold p-2 bg-red-50 rounded-md">
142
+ Your recipe percentages must add up to 100% for an accurate calculation.
143
+ </p>
144
+ )}
145
+ </div>
146
+ </div>
147
+ </div>
148
+ );
149
+ };
150
+
151
+ export default SoilVolumeCalculatorView;
views/SpeciesIdentifierView.tsx ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+ import React, { useState, useRef } from 'react';
4
+ import { ScanIcon, SparklesIcon, UploadCloudIcon, CheckCircleIcon, AlertTriangleIcon } from '../components/icons';
5
+ import Spinner from '../components/Spinner';
6
+ import { identifyBonsaiSpecies, isAIConfigured } from '../services/geminiService';
7
+ import type { View, SpeciesIdentificationResult } from '../types';
8
+ import { AppStatus } from '../types';
9
+
10
+
11
+ const SpeciesIdentifierView: React.FC<{ setActiveView: (view: View) => void }> = ({ setActiveView }) => {
12
+ const [status, setStatus] = useState<AppStatus>(AppStatus.IDLE);
13
+ const [image, setImage] = useState<{ preview: string; base64: string } | null>(null);
14
+ const [result, setResult] = useState<SpeciesIdentificationResult | null>(null);
15
+ const [error, setError] = useState<string>('');
16
+ const fileInputRef = useRef<HTMLInputElement>(null);
17
+ const aiConfigured = isAIConfigured();
18
+
19
+ const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
20
+ const file = event.target.files?.[0];
21
+ if (file) {
22
+ if (file.size > 4 * 1024 * 1024) { // 4MB limit
23
+ setError("File size exceeds 4MB. Please upload a smaller image.");
24
+ return;
25
+ }
26
+ const reader = new FileReader();
27
+ reader.onloadend = () => {
28
+ const base64String = (reader.result as string).split(',')[1];
29
+ setImage({ preview: reader.result as string, base64: base64String });
30
+ setError('');
31
+ setStatus(AppStatus.IDLE);
32
+ setResult(null);
33
+ };
34
+ reader.onerror = () => setError("Failed to read the file.");
35
+ reader.readAsDataURL(file);
36
+ }
37
+ };
38
+
39
+ const handleIdentify = async () => {
40
+ if (!image) {
41
+ setError("Please upload an image to identify.");
42
+ return;
43
+ }
44
+
45
+ setStatus(AppStatus.ANALYZING);
46
+ setError('');
47
+ setResult(null);
48
+
49
+ try {
50
+ const idResult = await identifyBonsaiSpecies(image.base64);
51
+ if (idResult && idResult.identifications.length > 0) {
52
+ setResult(idResult);
53
+ setStatus(AppStatus.SUCCESS);
54
+ } else {
55
+ throw new Error("Could not identify the species. The AI may be busy, or the image may not be clear enough. Please try a different photo.");
56
+ }
57
+ } catch (e: any) {
58
+ setError(e.message);
59
+ setStatus(AppStatus.ERROR);
60
+ }
61
+ };
62
+
63
+ const startFullAnalysis = (species: string) => {
64
+ // A bit of a hack to pass data to another view; for a larger app, a state manager (Context, Redux) would be better.
65
+ window.sessionStorage.setItem('prefilled-species', species);
66
+ setActiveView('steward');
67
+ }
68
+
69
+ const renderContent = () => {
70
+ switch (status) {
71
+ case AppStatus.ANALYZING:
72
+ return <Spinner text="Yuki is examining the leaves..." />;
73
+ case AppStatus.SUCCESS:
74
+ if (!result) return null;
75
+ return (
76
+ <div className="space-y-4">
77
+ <h3 className="text-xl font-bold text-center text-stone-800">Identification Results</h3>
78
+ {result.identifications.map((id, index) => (
79
+ <div key={index} className="bg-white p-6 rounded-xl shadow-md border border-stone-200">
80
+ <div className="flex justify-between items-baseline mb-2">
81
+ <h4 className="text-lg font-bold text-stone-900">{id.commonName}</h4>
82
+ <span className={`px-2 py-0.5 text-xs font-medium rounded-full ${id.confidence === 'High' ? 'bg-green-100 text-green-800' : id.confidence === 'Medium' ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800'}`}>{id.confidence} Confidence</span>
83
+ </div>
84
+ <p className="text-sm font-mono text-stone-500 mb-3">{id.scientificName}</p>
85
+ <p className="text-sm text-stone-700"><strong className="font-medium text-stone-800">Reasoning:</strong> {id.reasoning}</p>
86
+ <div className="mt-4 p-4 bg-stone-50 rounded-lg">
87
+ <h5 className="font-semibold text-stone-800">General Care Summary</h5>
88
+ <p className="text-sm text-stone-600 mt-1">{id.generalCareSummary}</p>
89
+ </div>
90
+ {id.confidence === 'High' && index === 0 && (
91
+ <button
92
+ onClick={() => startFullAnalysis(id.commonName)}
93
+ className="mt-4 w-full flex items-center justify-center gap-2 rounded-md bg-green-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-600"
94
+ >
95
+ <SparklesIcon className="w-5 h-5" />
96
+ Get Full Analysis for {id.commonName}
97
+ </button>
98
+ )}
99
+ </div>
100
+ ))}
101
+ </div>
102
+ );
103
+ case AppStatus.ERROR:
104
+ return <p className="text-center text-red-600 p-4 bg-red-50 rounded-md">{error}</p>;
105
+ case AppStatus.IDLE:
106
+ default:
107
+ return (
108
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200">
109
+ <div onClick={() => fileInputRef.current?.click()} className="flex justify-center rounded-lg border-2 border-dashed border-stone-300 px-6 py-10 hover:border-green-600 transition-colors cursor-pointer">
110
+ <div className="text-center">
111
+ {image ? <img src={image.preview} alt="Bonsai preview" className="mx-auto h-40 w-auto rounded-md object-cover" /> : (
112
+ <>
113
+ <UploadCloudIcon className="mx-auto h-12 w-12 text-stone-400" />
114
+ <p className="mt-2 text-sm font-semibold text-green-700">Upload a photo to identify</p>
115
+ <p className="text-xs text-stone-500">PNG, JPG up to 4MB</p>
116
+ </> )}
117
+ </div>
118
+ <input ref={fileInputRef} type="file" className="sr-only" onChange={handleFileChange} accept="image/png, image/jpeg" />
119
+ </div>
120
+ {error && <p className="text-sm text-red-600 mt-2">{error}</p>}
121
+ <button onClick={handleIdentify} disabled={!image || !aiConfigured} className="w-full mt-6 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">
122
+ <CheckCircleIcon className="w-5 h-5"/> Identify Species
123
+ </button>
124
+ {!aiConfigured && (
125
+ <div className="mt-4 p-3 bg-yellow-50 text-yellow-800 rounded-lg border border-yellow-200 text-center">
126
+ <p className="text-sm">
127
+ AI features are disabled. Please set your Gemini API key in the{' '}
128
+ <button onClick={() => setActiveView('settings')} className="font-bold underline hover:text-yellow-900">
129
+ Settings page
130
+ </button>.
131
+ </p>
132
+ </div>
133
+ )}
134
+ </div>
135
+ );
136
+ }
137
+ }
138
+
139
+
140
+ return (
141
+ <div className="space-y-8 max-w-2xl mx-auto">
142
+ <header className="text-center">
143
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
144
+ <ScanIcon className="w-8 h-8 text-green-600" />
145
+ Species Identifier
146
+ </h2>
147
+ <p className="mt-4 text-lg leading-8 text-stone-600">
148
+ Don't know what kind of tree you have? Upload a photo and let our AI identify it for you.
149
+ </p>
150
+ </header>
151
+
152
+ <div>
153
+ {renderContent()}
154
+ </div>
155
+ </div>
156
+ );
157
+ };
158
+
159
+ export default SpeciesIdentifierView;
views/SunTrackerView.tsx ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import SunCalc from 'suncalc';
3
+ import { SunClockIcon, AlertTriangleIcon } from '../components/icons';
4
+
5
+ type PermissionStatus = 'idle' | 'prompting' | 'granted' | 'denied';
6
+
7
+ const SunTrackerView: React.FC = () => {
8
+ const [permission, setPermission] = useState<PermissionStatus>('idle');
9
+ const [sunData, setSunData] = useState<any>(null);
10
+ const [compassHeading, setCompassHeading] = useState<number>(0);
11
+ const [error, setError] = useState<string>('');
12
+
13
+ const handleOrientation = (event: DeviceOrientationEvent) => {
14
+ // webkitCompassHeading is for iOS
15
+ const heading = (event as any).webkitCompassHeading || (360 - event.alpha!);
16
+ setCompassHeading(heading);
17
+ };
18
+
19
+ const requestPermissions = useCallback(async () => {
20
+ setPermission('prompting');
21
+
22
+ // Geolocation
23
+ if (!("geolocation" in navigator)) {
24
+ setError("Geolocation is not supported by your browser.");
25
+ setPermission('denied');
26
+ return;
27
+ }
28
+
29
+ navigator.geolocation.getCurrentPosition(
30
+ (position) => {
31
+ const { latitude, longitude } = position.coords;
32
+ const now = new Date();
33
+ const times = SunCalc.getTimes(now, latitude, longitude);
34
+ const pos = SunCalc.getPosition(now, latitude, longitude);
35
+ setSunData({ ...times, ...pos });
36
+
37
+ // Device Orientation (Compass)
38
+ if (typeof (DeviceOrientationEvent as any).requestPermission === 'function') {
39
+ (DeviceOrientationEvent as any).requestPermission()
40
+ .then((response: string) => {
41
+ if (response === 'granted') {
42
+ window.addEventListener('deviceorientation', handleOrientation);
43
+ setPermission('granted');
44
+ } else {
45
+ setError("Compass permission denied.");
46
+ setPermission('denied');
47
+ }
48
+ });
49
+ } else {
50
+ window.addEventListener('deviceorientation', handleOrientation);
51
+ setPermission('granted');
52
+ }
53
+ },
54
+ (error) => {
55
+ setError("Geolocation permission denied. Please enable it in your browser settings.");
56
+ setPermission('denied');
57
+ }
58
+ );
59
+ }, []);
60
+
61
+ useEffect(() => {
62
+ return () => {
63
+ window.removeEventListener('deviceorientation', handleOrientation);
64
+ };
65
+ }, []);
66
+
67
+ const renderCompass = () => (
68
+ <div className="relative w-48 h-48 mx-auto">
69
+ <div className="w-full h-full rounded-full bg-stone-100 border-4 border-stone-200 flex items-center justify-center">
70
+ <div className="absolute top-0 w-px h-full bg-stone-300"></div>
71
+ <div className="absolute left-0 h-px w-full bg-stone-300"></div>
72
+ <span className="absolute top-1 text-lg font-bold text-red-600">N</span>
73
+ <span className="absolute bottom-1 text-lg font-bold text-stone-600">S</span>
74
+ <span className="absolute left-2 text-lg font-bold text-stone-600">W</span>
75
+ <span className="absolute right-2 text-lg font-bold text-stone-600">E</span>
76
+ </div>
77
+ {sunData && (
78
+ <div className="absolute inset-0 flex items-center justify-center transform" style={{ transform: `rotate(${-compassHeading}deg)` }}>
79
+ <div className="w-8 h-8 bg-yellow-400 rounded-full shadow-lg"
80
+ style={{ transform: `rotate(${sunData.azimuth * 180 / Math.PI}deg) translateY(-50px) rotate(${-sunData.azimuth * 180 / Math.PI}deg) `}}
81
+ title={`Sun Position: Azimuth ${ (sunData.azimuth * 180 / Math.PI + 180).toFixed(0) }°`}
82
+ />
83
+ </div>
84
+ )}
85
+ <div className="absolute inset-0 flex items-center justify-center transform transition-transform duration-500" style={{ transform: `rotate(${compassHeading}deg)` }}>
86
+ <div className="w-0 h-0 border-l-8 border-l-transparent border-r-8 border-r-transparent border-b-16 border-b-red-600 transform -translate-y-12"></div>
87
+ </div>
88
+ </div>
89
+ );
90
+
91
+ const renderSunPath = () => {
92
+ if (!sunData) return null;
93
+ const now = new Date();
94
+ const totalDaylight = sunData.sunset.getTime() - sunData.sunrise.getTime();
95
+ const fromSunrise = now.getTime() - sunData.sunrise.getTime();
96
+ const percentOfDay = Math.max(0, Math.min(1, fromSunrise / totalDaylight));
97
+
98
+ return (
99
+ <div className="relative h-24 w-full">
100
+ <svg viewBox="0 0 200 100" className="w-full h-full">
101
+ <path d="M 10 90 A 90 90 0 0 1 190 90" stroke="#d6d3d1" strokeWidth="4" fill="none" />
102
+ {percentOfDay > 0 && percentOfDay < 1 && (
103
+ <circle
104
+ cx={10 + percentOfDay * 180}
105
+ cy={90 - Math.sin(percentOfDay * Math.PI) * 80}
106
+ r="8"
107
+ fill="#facc15"
108
+ stroke="#ca8a04"
109
+ strokeWidth="2"
110
+ />
111
+ )}
112
+ </svg>
113
+ <div className="absolute top-full w-full flex justify-between text-xs font-semibold text-stone-600">
114
+ <span>{sunData.sunrise.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
115
+ <span>{sunData.sunset.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
116
+ </div>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ return (
122
+ <div className="space-y-8 max-w-2xl mx-auto">
123
+ <header className="text-center">
124
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
125
+ <SunClockIcon className="w-8 h-8 text-yellow-500" />
126
+ Sun Tracker
127
+ </h2>
128
+ <p className="mt-4 text-lg leading-8 text-stone-600">
129
+ Find the optimal light for your trees. Use this tool to see the sun's path and current position.
130
+ </p>
131
+ </header>
132
+
133
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200">
134
+ {permission === 'granted' ? (
135
+ <div className="space-y-6">
136
+ {renderCompass()}
137
+ <div className="text-center">
138
+ <p className="text-lg font-bold text-stone-800">Heading: {compassHeading.toFixed(0)}°</p>
139
+ <p className="text-sm text-stone-600">Point the top of your device North</p>
140
+ </div>
141
+ {renderSunPath()}
142
+ </div>
143
+ ) : (
144
+ <div className="text-center py-8">
145
+ <p className="mb-4 text-stone-700">This tool requires permission to access your device's location and orientation to function.</p>
146
+ <button onClick={requestPermissions} disabled={permission === 'prompting'} className="bg-yellow-500 text-white font-bold py-3 px-6 rounded-lg hover:bg-yellow-600 transition-colors disabled:bg-stone-400">
147
+ {permission === 'prompting' ? 'Waiting for Permission...' : 'Activate Sun Tracker'}
148
+ </button>
149
+ {error && (
150
+ <div className="mt-6 p-4 bg-red-50 text-red-700 rounded-lg flex items-center gap-3">
151
+ <AlertTriangleIcon className="w-6 h-6 flex-shrink-0" />
152
+ <p className="text-left">{error}</p>
153
+ </div>
154
+ )}
155
+ </div>
156
+ )}
157
+ </div>
158
+ </div>
159
+ );
160
+ };
161
+
162
+ export default SunTrackerView;
views/ToolGuideView.tsx ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { WrenchIcon, PlusCircleIcon, ToolboxIcon, InfoIcon, Trash2Icon, SparklesIcon, BookOpenIcon, CheckCircleIcon } from '../components/icons';
3
+ import Spinner from '../components/Spinner';
4
+ import { generateToolGuide, generateMaintenanceTips, isAIConfigured } from '../services/geminiService';
5
+ import type { ToolRecommendation, UserTool, MaintenanceTips } from '../types';
6
+ import { AppStatus, ToolCondition } from '../types';
7
+ import { useLocalStorage } from '../hooks/useLocalStorage';
8
+
9
+
10
+ const ToolGuideView: React.FC = () => {
11
+ const [status, setStatus] = useState<AppStatus>(AppStatus.IDLE);
12
+ const [toolData, setToolData] = useState<ToolRecommendation[] | null>(null);
13
+ const [myToolkit, setMyToolkit] = useLocalStorage<UserTool[]>('user-toolkit', []);
14
+ const [error, setError] = useState<string>('');
15
+ const [activeTab, setActiveTab] = useState<'encyclopedia' | 'toolkit'>('encyclopedia');
16
+
17
+ const [modalOpen, setModalOpen] = useState(false);
18
+ const [modalContent, setModalContent] = useState<{toolName: string, tips: MaintenanceTips} | null>(null);
19
+ const [modalLoading, setModalLoading] = useState(false);
20
+ const aiConfigured = isAIConfigured();
21
+
22
+ useEffect(() => {
23
+ const fetchToolGuide = async () => {
24
+ if (!aiConfigured) {
25
+ setStatus(AppStatus.ERROR);
26
+ setError('Please set your Gemini API key in Settings to load the tool encyclopedia.');
27
+ return;
28
+ }
29
+ setStatus(AppStatus.ANALYZING);
30
+ try {
31
+ const result = await generateToolGuide();
32
+ if (result) {
33
+ setToolData(result);
34
+ setStatus(AppStatus.SUCCESS);
35
+ } else {
36
+ throw new Error('Failed to load tool encyclopedia. The AI may be busy. Please try again.');
37
+ }
38
+ } catch (e: any) {
39
+ setError(e.message);
40
+ setStatus(AppStatus.ERROR);
41
+ }
42
+ };
43
+ fetchToolGuide();
44
+ }, [aiConfigured]);
45
+
46
+ const handleAddToToolkit = (tool: ToolRecommendation) => {
47
+ const newUserTool: UserTool = {
48
+ ...tool,
49
+ id: `${tool.name}-${Date.now()}`,
50
+ condition: ToolCondition.GOOD,
51
+ };
52
+ setMyToolkit(prev => [...prev, newUserTool]);
53
+ };
54
+
55
+ const handleRemoveFromToolkit = (toolId: string) => {
56
+ setMyToolkit(prev => prev.filter(t => t.id !== toolId));
57
+ };
58
+
59
+ const handleUpdateTool = (toolId: string, updatedProps: Partial<UserTool>) => {
60
+ setMyToolkit(prev => prev.map(t => t.id === toolId ? { ...t, ...updatedProps } : t));
61
+ }
62
+
63
+ const handleShowMaintenanceTips = async (tool: UserTool) => {
64
+ setModalOpen(true);
65
+ setModalLoading(true);
66
+ try {
67
+ const tips = await generateMaintenanceTips(tool.name);
68
+ if (tips) {
69
+ setModalContent({ toolName: tool.name, tips });
70
+ } else {
71
+ throw new Error("Could not retrieve tips.");
72
+ }
73
+ } catch(e: any) {
74
+ setModalContent({
75
+ toolName: tool.name,
76
+ tips: { sharpening: e.message, cleaning: "Could not retrieve tips.", storage: "Could not retrieve tips."}
77
+ });
78
+ } finally {
79
+ setModalLoading(false);
80
+ }
81
+ };
82
+
83
+ const isToolInKit = (toolName: string) => myToolkit.some(t => t.name === toolName);
84
+
85
+ const conditionColors: Record<ToolCondition, { bg: string, text: string, ring: string }> = {
86
+ [ToolCondition.EXCELLENT]: { bg: 'bg-green-100', text: 'text-green-800', ring: 'ring-green-600' },
87
+ [ToolCondition.GOOD]: { bg: 'bg-blue-100', text: 'text-blue-800', ring: 'ring-blue-600' },
88
+ [ToolCondition.NEEDS_SHARPENING]: { bg: 'bg-yellow-100', text: 'text-yellow-800', ring: 'ring-yellow-600' },
89
+ [ToolCondition.NEEDS_OILING]: { bg: 'bg-orange-100', text: 'text-orange-800', ring: 'ring-orange-600' },
90
+ [ToolCondition.DAMAGED]: { bg: 'bg-red-100', text: 'text-red-800', ring: 'ring-red-600' },
91
+ };
92
+
93
+ const renderEncyclopedia = () => {
94
+ if (status === AppStatus.ANALYZING) return <Spinner text="Yuki is organizing the tool shed..." />;
95
+ if (status === AppStatus.ERROR) return <p className="text-center text-red-600 p-4 bg-red-50 rounded-md">{error}</p>;
96
+ if (!toolData) return null;
97
+
98
+ const groupedTools = toolData.reduce((acc, tool) => {
99
+ acc[tool.category] = acc[tool.category] || [];
100
+ acc[tool.category].push(tool);
101
+ return acc;
102
+ }, {} as Record<ToolRecommendation['category'], ToolRecommendation[]>);
103
+
104
+ return (
105
+ <div className="space-y-6">
106
+ {Object.entries(groupedTools).map(([category, tools]) => (
107
+ <div key={category} className="bg-white rounded-xl shadow-md border border-stone-200 p-6">
108
+ <h3 className="text-xl font-semibold text-stone-800 mb-4">{category} Tools</h3>
109
+ <div className="divide-y divide-stone-100">
110
+ {tools.sort((a,b) => {
111
+ const levels = { 'Essential': 1, 'Recommended': 2, 'Advanced': 3 };
112
+ return levels[a.level] - levels[b.level];
113
+ }).map(tool => (
114
+ <div key={tool.name} className="py-3">
115
+ <div className="flex justify-between items-start gap-4">
116
+ <div className="flex-1">
117
+ <p className="font-semibold text-stone-800">{tool.name}</p>
118
+ <p className="text-sm text-stone-600">{tool.description}</p>
119
+ </div>
120
+ <div className="flex flex-col items-end gap-2">
121
+ <span className={`px-2 py-0.5 text-xs font-medium rounded-full ${tool.level === 'Essential' ? 'bg-green-100 text-green-800' : tool.level === 'Recommended' ? 'bg-blue-100 text-blue-800' : 'bg-orange-100 text-orange-800'}`}>{tool.level}</span>
122
+ <button
123
+ onClick={() => handleAddToToolkit(tool)}
124
+ disabled={isToolInKit(tool.name)}
125
+ className="flex items-center gap-1 text-xs font-semibold text-green-700 hover:text-green-600 disabled:text-stone-400 disabled:cursor-not-allowed transition-colors"
126
+ >
127
+ {isToolInKit(tool.name) ? <CheckCircleIcon className="w-4 h-4" /> : <PlusCircleIcon className="w-4 h-4" />}
128
+ {isToolInKit(tool.name) ? 'In Toolkit' : 'Add to Toolkit'}
129
+ </button>
130
+ </div>
131
+ </div>
132
+ </div>
133
+ ))}
134
+ </div>
135
+ </div>
136
+ ))}
137
+ </div>
138
+ );
139
+ };
140
+
141
+ const renderMyToolkit = () => {
142
+ if (myToolkit.length === 0) {
143
+ return (
144
+ <div className="text-center bg-white rounded-xl shadow-md border border-stone-200 p-12">
145
+ <ToolboxIcon className="mx-auto h-16 w-16 text-stone-400" />
146
+ <h3 className="mt-4 text-xl font-semibold text-stone-800">Your Toolkit is Empty</h3>
147
+ <p className="mt-2 text-stone-600">Add tools from the "Tool Encyclopedia" tab to start managing your collection.</p>
148
+ </div>
149
+ )
150
+ }
151
+
152
+ return (
153
+ <div className="space-y-4">
154
+ {myToolkit.map(tool => (
155
+ <div key={tool.id} className="bg-white rounded-xl shadow-md border border-stone-200 p-4 transition-shadow hover:shadow-lg">
156
+ <div className="flex flex-col sm:flex-row gap-4">
157
+ <div className="flex-1">
158
+ <p className="font-bold text-lg text-stone-900">{tool.name}</p>
159
+ <p className="text-sm text-stone-500">{tool.description}</p>
160
+ </div>
161
+ <div className="flex flex-col sm:items-end gap-2">
162
+ <select
163
+ value={tool.condition}
164
+ onChange={(e) => handleUpdateTool(tool.id, { condition: e.target.value as ToolCondition })}
165
+ className={`w-full sm:w-auto text-sm font-medium border-0 rounded-md shadow-sm focus:ring-2 focus:ring-offset-2 ${conditionColors[tool.condition].bg} ${conditionColors[tool.condition].text} ${conditionColors[tool.condition].ring}`}
166
+ >
167
+ {Object.values(ToolCondition).map(c => <option key={c} value={c}>{c}</option>)}
168
+ </select>
169
+ <p className="text-xs text-stone-500">
170
+ Last Maintained: {tool.lastMaintained ? new Date(tool.lastMaintained).toLocaleDateString() : 'N/A'}
171
+ </p>
172
+ </div>
173
+ </div>
174
+ <div className="mt-4 pt-4 border-t border-stone-100 flex flex-col sm:flex-row items-center gap-4">
175
+ <div className="flex-1 w-full">
176
+ <label htmlFor={`notes-${tool.id}`} className="text-xs font-bold text-stone-500 uppercase">Notes</label>
177
+ <textarea
178
+ id={`notes-${tool.id}`}
179
+ rows={2}
180
+ placeholder="Add notes about this tool..."
181
+ value={tool.notes || ''}
182
+ onChange={(e) => handleUpdateTool(tool.id, { notes: e.target.value })}
183
+ className="block mt-1 w-full rounded-md border-stone-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm text-stone-800"
184
+ />
185
+ </div>
186
+ <div className="flex-shrink-0 flex sm:flex-col items-center gap-2 w-full sm:w-auto">
187
+ <button onClick={() => handleUpdateTool(tool.id, { lastMaintained: new Date().toISOString() })} className="w-full sm:w-auto flex items-center justify-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">Log Maintenance</button>
188
+ <button onClick={() => handleShowMaintenanceTips(tool)} disabled={!aiConfigured} className="w-full sm:w-auto flex items-center justify-center gap-2 text-sm font-semibold bg-stone-100 text-stone-700 hover:bg-stone-200 px-3 py-2 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"><InfoIcon className="w-4 h-4"/>Tips</button>
189
+ <button onClick={() => handleRemoveFromToolkit(tool.id)} className="w-full sm:w-auto flex items-center justify-center gap-2 text-sm font-semibold bg-red-100 text-red-700 hover:bg-red-200 px-3 py-2 rounded-md transition-colors"><Trash2Icon className="w-4 h-4"/>Remove</button>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ ))}
194
+ </div>
195
+ )
196
+ };
197
+
198
+ const renderModal = () => (
199
+ <div className={`fixed inset-0 z-50 flex items-center justify-center transition-opacity ${modalOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
200
+ <div className="absolute inset-0 bg-black/50" onClick={() => setModalOpen(false)}></div>
201
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-lg m-4 p-6 relative transform transition-transform scale-95" style={{transform: modalOpen ? 'scale(1)' : 'scale(0.95)'}}>
202
+ {modalLoading ? <Spinner text="Yuki is fetching maintenance tips..." /> : (
203
+ modalContent && <>
204
+ <h3 className="text-2xl font-bold text-stone-900">Maintenance for {modalContent.toolName}</h3>
205
+ <div className="mt-4 space-y-4 text-stone-600">
206
+ <div>
207
+ <h4 className="font-semibold text-stone-800">Cleaning</h4>
208
+ <p>{modalContent.tips.cleaning}</p>
209
+ </div>
210
+ <div>
211
+ <h4 className="font-semibold text-stone-800">Sharpening</h4>
212
+ <p>{modalContent.tips.sharpening}</p>
213
+ </div>
214
+ <div>
215
+ <h4 className="font-semibold text-stone-800">Storage</h4>
216
+ <p>{modalContent.tips.storage}</p>
217
+ </div>
218
+ </div>
219
+ <button onClick={() => setModalOpen(false)} className="mt-6 w-full bg-green-700 text-white font-semibold py-2 px-4 rounded-lg hover:bg-green-600 transition-colors">
220
+ Close
221
+ </button>
222
+ </>)}
223
+ </div>
224
+ </div>
225
+ );
226
+
227
+ return (
228
+ <div className="space-y-8 max-w-5xl mx-auto">
229
+ <header className="text-center">
230
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
231
+ <WrenchIcon className="w-8 h-8 text-gray-600" />
232
+ Bonsai Tool Shed
233
+ </h2>
234
+ <p className="mt-4 text-lg leading-8 text-stone-600">
235
+ Explore the encyclopedia of bonsai tools and manage your personal toolkit.
236
+ </p>
237
+ </header>
238
+
239
+ <div className="bg-white p-2 rounded-xl shadow-lg border border-stone-200">
240
+ <div className="flex gap-2">
241
+ <button onClick={() => setActiveTab('encyclopedia')} className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors ${activeTab === 'encyclopedia' ? 'bg-green-700 text-white shadow' : 'bg-transparent text-stone-600 hover:bg-stone-100'}`}>
242
+ <BookOpenIcon className="w-5 h-5" /> Tool Encyclopedia
243
+ </button>
244
+ <button onClick={() => setActiveTab('toolkit')} className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors ${activeTab === 'toolkit' ? 'bg-green-700 text-white shadow' : 'bg-transparent text-stone-600 hover:bg-stone-100'}`}>
245
+ <ToolboxIcon className="w-5 h-5" /> My Toolkit ({myToolkit.length})
246
+ </button>
247
+ </div>
248
+ </div>
249
+
250
+ <div>
251
+ {activeTab === 'encyclopedia' ? renderEncyclopedia() : renderMyToolkit()}
252
+ </div>
253
+
254
+ {renderModal()}
255
+ </div>
256
+ );
257
+ };
258
+
259
+ export default ToolGuideView;
views/VirtualTrimmerView.tsx ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+ import React, { useState, useRef } from 'react';
4
+ import { ScissorsIcon, SparklesIcon, UploadCloudIcon, DownloadIcon, AlertTriangleIcon } from '../components/icons';
5
+ import Spinner from '../components/Spinner';
6
+ import { AppStatus } from '../types';
7
+ import { editBonsaiWithKontext } from '../services/mcpService';
8
+ import { isAIConfigured } from '../services/geminiService'; // Though this view doesn't use Gemini, we can check for consistency
9
+ import type { View } from '../types';
10
+
11
+ const VirtualTrimmerView: React.FC<{ setActiveView: (view: View) => void }> = ({ setActiveView }) => {
12
+ const [status, setStatus] = useState<AppStatus>(AppStatus.IDLE);
13
+ const [image, setImage] = useState<{ preview: string; base64: string } | null>(null);
14
+ const [editedImage, setEditedImage] = useState<string | null>(null);
15
+ const [prompt, setPrompt] = useState<string>('');
16
+ const [error, setError] = useState<string>('');
17
+ const fileInputRef = useRef<HTMLInputElement>(null);
18
+ const aiConfigured = isAIConfigured(); // We check this mainly to keep the UI consistent
19
+
20
+ const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
21
+ const file = event.target.files?.[0];
22
+ if (file) {
23
+ if (file.size > 4 * 1024 * 1024) { // 4MB limit
24
+ setError("File size exceeds 4MB. Please upload a smaller image.");
25
+ return;
26
+ }
27
+ const reader = new FileReader();
28
+ reader.onloadend = () => {
29
+ const base64String = (reader.result as string).split(',')[1];
30
+ setImage({ preview: reader.result as string, base64: base64String });
31
+ setEditedImage(null);
32
+ setError('');
33
+ setStatus(AppStatus.IDLE);
34
+ };
35
+ reader.onerror = () => setError("Failed to read the file.");
36
+ reader.readAsDataURL(file);
37
+ }
38
+ };
39
+
40
+ const handleGenerate = async () => {
41
+ if (!image) {
42
+ setError("Please upload an image first.");
43
+ return;
44
+ }
45
+ if (!prompt.trim()) {
46
+ setError("Please enter an editing instruction.");
47
+ return;
48
+ }
49
+
50
+ setStatus(AppStatus.ANALYZING);
51
+ setError('');
52
+ setEditedImage(null);
53
+
54
+ const result = await editBonsaiWithKontext(image.base64, prompt);
55
+
56
+ if (result) {
57
+ setEditedImage(result);
58
+ setStatus(AppStatus.SUCCESS);
59
+ } else {
60
+ setError('Failed to generate edit. The AI model may be busy or the request could not be completed. Please try again.');
61
+ setStatus(AppStatus.ERROR);
62
+ }
63
+ };
64
+
65
+ const presetPrompts = [
66
+ "Trim the lowest branch on the left.",
67
+ "Remove all dead leaves.",
68
+ "Make the apex more rounded.",
69
+ "Slightly shorten the longest branch on the right.",
70
+ "Make the foliage pads more dense and defined.",
71
+ "Remove the small branch growing towards the viewer.",
72
+ ];
73
+
74
+ return (
75
+ <div className="space-y-8 max-w-7xl mx-auto">
76
+ <header className="text-center">
77
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
78
+ <ScissorsIcon className="w-8 h-8 text-green-600" />
79
+ Virtual Trimmer
80
+ </h2>
81
+ <p className="mt-4 text-lg leading-8 text-stone-600 max-w-3xl mx-auto">
82
+ Visualize changes to your bonsai before you make a single cut. Describe your edit and let the AI show you the result.
83
+ </p>
84
+ </header>
85
+
86
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
87
+ {/* Controls */}
88
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-4 lg:col-span-1">
89
+ <div>
90
+ <label className="block text-sm font-medium text-stone-900">1. Upload Photo</label>
91
+ <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">
92
+ <div className="text-center">
93
+ {image ? <p className="text-green-700 font-semibold">Image Loaded!</p> : <UploadCloudIcon className="mx-auto h-10 w-10 text-stone-400" />}
94
+ <p className="mt-1 text-sm text-stone-600">{image ? 'Click to change image' : 'Click to upload'}</p>
95
+ </div>
96
+ <input ref={fileInputRef} type="file" className="sr-only" onChange={handleFileChange} accept="image/png, image/jpeg" />
97
+ </div>
98
+ </div>
99
+
100
+ <div>
101
+ <label htmlFor="edit-prompt" className="block text-sm font-medium text-stone-900">2. Describe Your Edit</label>
102
+ <textarea
103
+ id="edit-prompt"
104
+ rows={4}
105
+ value={prompt}
106
+ onChange={e => setPrompt(e.target.value)}
107
+ className="mt-1 block w-full rounded-md border-stone-300 shadow-sm focus:border-green-500 focus:ring-green-500"
108
+ placeholder="e.g., 'Trim the top to be more rounded'"
109
+ />
110
+ </div>
111
+ <div className="space-y-2">
112
+ <p className="text-sm font-medium text-stone-700">Or try a preset edit:</p>
113
+ <div className="flex flex-wrap gap-2">
114
+ {presetPrompts.map((p, i) => (
115
+ <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">
116
+ {p}
117
+ </button>
118
+ ))}
119
+ </div>
120
+ </div>
121
+
122
+ <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">
123
+ <SparklesIcon className="w-5 h-5"/> {status === AppStatus.ANALYZING ? 'Generating Edit...' : '3. Generate Edit'}
124
+ </button>
125
+ {error && <p className="text-sm text-red-600 mt-2 bg-red-50 p-3 rounded-md">{error}</p>}
126
+ {!aiConfigured && (
127
+ <div className="mt-4 p-3 bg-yellow-50 text-yellow-800 rounded-lg border border-yellow-200 text-center">
128
+ <p className="text-sm">
129
+ This experimental feature relies on AI. Please set your Gemini API key in the{' '}
130
+ <button onClick={() => setActiveView('settings')} className="font-bold underline hover:text-yellow-900">
131
+ Settings page
132
+ </button>
133
+ {' '}to enable it.
134
+ </p>
135
+ </div>
136
+ )}
137
+ </div>
138
+
139
+ {/* Display */}
140
+ <div className="bg-white p-4 rounded-xl shadow-lg border border-stone-200 lg:col-span-2">
141
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
142
+ <div className="text-center">
143
+ <h3 className="text-lg font-bold text-stone-800 mb-2">Original</h3>
144
+ <div className="aspect-square bg-stone-100 rounded-lg flex items-center justify-center">
145
+ {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>}
146
+ </div>
147
+ </div>
148
+ <div className="text-center">
149
+ <h3 className="text-lg font-bold text-stone-800 mb-2">Edited</h3>
150
+ <div className="aspect-square bg-stone-100 rounded-lg flex items-center justify-center relative">
151
+ {status === AppStatus.ANALYZING && <Spinner text="AI is trimming..." />}
152
+ {status === AppStatus.SUCCESS && editedImage && (
153
+ <>
154
+ <img src={`data:image/jpeg;base64,${editedImage}`} alt="Edited bonsai" className="max-h-full max-w-full object-contain rounded-lg"/>
155
+ <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">
156
+ <DownloadIcon className="w-5 h-5 text-stone-700"/>
157
+ </a>
158
+ </>
159
+ )}
160
+ {status !== AppStatus.ANALYZING && !editedImage && <p className="text-stone-500">Your edited image will appear here</p>}
161
+ </div>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ </div>
166
+ </div>
167
+ );
168
+ };
169
+
170
+ export default VirtualTrimmerView;
views/WeatherShieldView.tsx ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect } from 'react';
3
+ import { UmbrellaIcon, SnowflakeIcon, SunIcon, WindIcon, AlertTriangleIcon } from '../components/icons';
4
+ import { useLocalStorage } from '../hooks/useLocalStorage';
5
+ import type { BonsaiTree } from '../types';
6
+ import Spinner from '../components/Spinner';
7
+
8
+ interface WeatherData {
9
+ daily: {
10
+ time: string[];
11
+ weathercode: number[];
12
+ temperature_2m_max: number[];
13
+ temperature_2m_min: number[];
14
+ windspeed_10m_max: number[];
15
+ };
16
+ }
17
+
18
+ const WeatherShieldView: React.FC = () => {
19
+ const [trees] = useLocalStorage<BonsaiTree[]>('bonsai-diary-trees', []);
20
+ const [weather, setWeather] = useState<WeatherData | null>(null);
21
+ const [isLoading, setIsLoading] = useState(true);
22
+ const [error, setError] = useState<string>('');
23
+
24
+ useEffect(() => {
25
+ const fetchWeather = async () => {
26
+ if (trees.length === 0) {
27
+ setIsLoading(false);
28
+ return;
29
+ }
30
+
31
+ // For simplicity, use the location of the first tree as the primary location
32
+ const primaryLocation = trees[0].location;
33
+ if (!primaryLocation) {
34
+ setError("No location set for your trees. Please add a location in 'My Garden'.");
35
+ setIsLoading(false);
36
+ return;
37
+ }
38
+
39
+ try {
40
+ // Simple geocoding using a public API
41
+ const geoResponse = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(primaryLocation)}&count=1`);
42
+ const geoData = await geoResponse.json();
43
+
44
+ if (!geoData.results || geoData.results.length === 0) {
45
+ throw new Error(`Could not find location: ${primaryLocation}`);
46
+ }
47
+ const { latitude, longitude } = geoData.results[0];
48
+
49
+ // Fetch weather forecast
50
+ const weatherResponse = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,windspeed_10m_max&timezone=auto`);
51
+ const weatherData = await weatherResponse.json();
52
+ setWeather(weatherData);
53
+
54
+ } catch (err) {
55
+ if (err instanceof Error) {
56
+ setError(`Failed to fetch weather data: ${err.message}`);
57
+ } else {
58
+ setError("An unknown error occurred while fetching weather data.");
59
+ }
60
+ } finally {
61
+ setIsLoading(false);
62
+ }
63
+ };
64
+
65
+ fetchWeather();
66
+ }, [trees]);
67
+
68
+ const getWeatherIcon = (code: number) => {
69
+ if (code >= 200 && code < 600) return <UmbrellaIcon className="w-8 h-8 text-blue-500" />; // Rain
70
+ if (code >= 600 && code < 700) return <SnowflakeIcon className="w-8 h-8 text-cyan-400" />; // Snow
71
+ if (code === 800) return <SunIcon className="w-8 h-8 text-yellow-500" />; // Clear
72
+ return <SunIcon className="w-8 h-8 text-stone-500" />; // Default/Clouds
73
+ };
74
+
75
+ const generateAlertsForTree = (tree: BonsaiTree) => {
76
+ if (!weather || !tree.protectionProfile || !tree.protectionProfile.alertsEnabled) return [];
77
+
78
+ const alerts: string[] = [];
79
+ weather.daily.time.slice(0, 5).forEach((_, i) => {
80
+ const dayMinTemp = weather.daily.temperature_2m_min[i];
81
+ const dayMaxTemp = weather.daily.temperature_2m_max[i];
82
+ const dayMaxWind = weather.daily.windspeed_10m_max[i];
83
+
84
+ if (dayMinTemp < tree.protectionProfile!.minTempC) {
85
+ alerts.push(`Frost Alert for ${new Date(weather.daily.time[i]).toLocaleDateString([], {weekday: 'short'})}: Low of ${dayMinTemp.toFixed(0)}°C`);
86
+ }
87
+ if (dayMaxTemp > tree.protectionProfile!.maxTempC) {
88
+ alerts.push(`Heat Alert for ${new Date(weather.daily.time[i]).toLocaleDateString([], {weekday: 'short'})}: High of ${dayMaxTemp.toFixed(0)}°C`);
89
+ }
90
+ if (dayMaxWind > tree.protectionProfile!.maxWindKph) {
91
+ alerts.push(`Wind Alert for ${new Date(weather.daily.time[i]).toLocaleDateString([], {weekday: 'short'})}: Gusts up to ${dayMaxWind.toFixed(0)} km/h`);
92
+ }
93
+ });
94
+ return alerts;
95
+ }
96
+
97
+
98
+ return (
99
+ <div className="space-y-8 max-w-6xl mx-auto">
100
+ <header className="text-center">
101
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
102
+ <UmbrellaIcon className="w-8 h-8 text-blue-600" />
103
+ Weather Shield
104
+ </h2>
105
+ <p className="mt-4 text-lg leading-8 text-stone-600">
106
+ Proactive weather alerts for your bonsai collection, based on their individual needs.
107
+ </p>
108
+ </header>
109
+
110
+ {isLoading ? <Spinner text="Fetching local forecast..."/> :
111
+ error ? <p className="text-center text-red-600 p-4 bg-red-50 rounded-md">{error}</p> :
112
+ !weather ? <p className="text-center text-stone-600">Add trees to your garden to see weather alerts.</p> :
113
+ (
114
+ <>
115
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200">
116
+ <h3 className="text-xl font-bold text-stone-800 mb-4">5-Day Forecast for {trees[0]?.location || 'Your Area'}</h3>
117
+ <div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-center">
118
+ {weather.daily.time.slice(0, 5).map((time, i) => (
119
+ <div key={time} className="bg-stone-50 p-4 rounded-lg border border-stone-100">
120
+ <p className="font-bold text-stone-900">{new Date(time).toLocaleDateString([], { weekday: 'short', day: 'numeric'})}</p>
121
+ <div className="my-2">{getWeatherIcon(weather.daily.weathercode[i])}</div>
122
+ <p className="font-semibold text-blue-600">{weather.daily.temperature_2m_min[i].toFixed(0)}°C</p>
123
+ <p className="font-semibold text-red-600">{weather.daily.temperature_2m_max[i].toFixed(0)}°C</p>
124
+ <p className="text-sm text-stone-500 mt-1 flex items-center justify-center gap-1"><WindIcon className="w-4 h-4" />{weather.daily.windspeed_10m_max[i].toFixed(0)} km/h</p>
125
+ </div>
126
+ ))}
127
+ </div>
128
+ </div>
129
+
130
+ <div className="space-y-4">
131
+ <h3 className="text-xl font-bold text-stone-800">Tree Protection Status</h3>
132
+ {trees.map(tree => {
133
+ const alerts = generateAlertsForTree(tree);
134
+ const hasAlerts = alerts.length > 0;
135
+ return (
136
+ <div key={tree.id} className={`bg-white p-4 rounded-lg shadow-md border-l-4 ${hasAlerts ? 'border-red-500' : 'border-green-500'}`}>
137
+ <h4 className="font-bold text-lg text-stone-900">{tree.name} <span className="text-sm font-normal text-stone-500">({tree.species})</span></h4>
138
+ {hasAlerts ? (
139
+ <div className="mt-2 space-y-1">
140
+ {alerts.map((alert, i) => (
141
+ <div key={i} className="flex items-center gap-2 text-sm text-red-700 font-semibold">
142
+ <AlertTriangleIcon className="w-4 h-4 flex-shrink-0" />
143
+ <p>{alert}</p>
144
+ </div>
145
+ ))}
146
+ </div>
147
+ ) : (
148
+ <p className="mt-1 text-sm text-green-700 font-medium">No weather threats detected in the next 5 days.</p>
149
+ )}
150
+ </div>
151
+ );
152
+ })}
153
+ </div>
154
+ </>
155
+ )
156
+ }
157
+ </div>
158
+ );
159
+ };
160
+
161
+ export default WeatherShieldView;
views/WiringGuideView.tsx ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+
4
+ import React, { useState, useRef, useLayoutEffect } from 'react';
5
+ import { SnailIcon, SparklesIcon, UploadCloudIcon, AlertTriangleIcon } from '../components/icons';
6
+ import Spinner from '../components/Spinner';
7
+ import { generateStylingBlueprint, isAIConfigured } from '../services/geminiService';
8
+ import { AppStatus, AnnotationType } from '../types';
9
+ import type { StylingBlueprint, StylingAnnotation, SvgPoint, View } from '../types';
10
+
11
+ const ANNOTATION_STYLES: { [key in AnnotationType]: React.CSSProperties } = {
12
+ [AnnotationType.PruneLine]: { stroke: '#ef4444', strokeWidth: 3, strokeDasharray: '6 4', fill: 'none' },
13
+ [AnnotationType.RemoveBranch]: { stroke: '#ef4444', strokeWidth: 2, fill: 'rgba(239, 68, 68, 0.3)' },
14
+ [AnnotationType.WireDirection]: { stroke: '#3b82f6', strokeWidth: 3, fill: 'none', markerEnd: 'url(#arrow-head-blue)' },
15
+ [AnnotationType.FoliageRefinement]: { stroke: '#22c55e', strokeWidth: 3, strokeDasharray: '8 5', fill: 'rgba(34, 197, 94, 0.2)' },
16
+ [AnnotationType.JinShari]: { stroke: '#a16207', strokeWidth: 2, fill: 'rgba(161, 98, 7, 0.25)', strokeDasharray: '3 3' },
17
+ [AnnotationType.TrunkLine]: { stroke: '#f97316', strokeWidth: 4, fill: 'none', opacity: 0.8, markerEnd: 'url(#arrow-head-orange)' },
18
+ [AnnotationType.ExposeRoot]: { stroke: '#9333ea', strokeWidth: 2, fill: 'rgba(147, 51, 234, 0.2)', strokeDasharray: '5 5' },
19
+ };
20
+
21
+ const SvgAnnotation: React.FC<{ annotation: StylingAnnotation, scale: { x: number, y: number } }> = ({ annotation, scale }) => {
22
+ const style = ANNOTATION_STYLES[annotation.type];
23
+ const { type, points, path, label } = annotation;
24
+
25
+ const transformPoints = (pts: SvgPoint[]): string => {
26
+ return pts.map(p => `${p.x * scale.x},${p.y * scale.y}`).join(' ');
27
+ };
28
+
29
+ const scalePath = (pathData: string): string => {
30
+ return pathData.replace(/([0-9.]+)/g, (match, number, offset) => {
31
+ const precedingChar = pathData[offset - 1];
32
+ const isY = precedingChar === ',' || (precedingChar === ' ' && pathData.substring(0, offset).split(' ').length % 2 === 0);
33
+ return isY ? (parseFloat(number) * scale.y).toFixed(2) : (parseFloat(number) * scale.x).toFixed(2);
34
+ });
35
+ };
36
+
37
+ const positionLabel = (): SvgPoint => {
38
+ if (points && points.length > 0) {
39
+ const total = points.reduce((acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }), { x: 0, y: 0 });
40
+ return { x: (total.x / points.length) * scale.x, y: (total.y / points.length) * scale.y };
41
+ }
42
+ if (path) {
43
+ const commands = path.split(' ');
44
+ const lastX = parseFloat(commands[commands.length - 2]);
45
+ const lastY = parseFloat(commands[commands.length - 1]);
46
+ return { x: lastX * scale.x, y: lastY * scale.y };
47
+ }
48
+ return { x: 0, y: 0 };
49
+ };
50
+
51
+ const labelPos = positionLabel();
52
+
53
+ const renderShape = () => {
54
+ switch (type) {
55
+ case AnnotationType.PruneLine:
56
+ return <polyline points={transformPoints(points || [])} style={style} />;
57
+ case AnnotationType.FoliageRefinement:
58
+ case AnnotationType.RemoveBranch:
59
+ case AnnotationType.JinShari:
60
+ case AnnotationType.ExposeRoot:
61
+ return <polygon points={transformPoints(points || [])} style={style} />;
62
+ case AnnotationType.WireDirection:
63
+ case AnnotationType.TrunkLine:
64
+ return <path d={scalePath(path || '')} style={style} />;
65
+ default:
66
+ return null;
67
+ }
68
+ };
69
+
70
+ return (
71
+ <g className="annotation-group transition-opacity hover:opacity-100 opacity-80">
72
+ {renderShape()}
73
+ <text x={labelPos.x + 5} y={labelPos.y - 5} fill="white" stroke="black" strokeWidth="0.5px" paintOrder="stroke" fontSize="14" fontWeight="bold" className="pointer-events-none">
74
+ {label}
75
+ </text>
76
+ </g>
77
+ );
78
+ };
79
+
80
+
81
+ const WiringGuideView: React.FC<{ setActiveView: (view: View) => void }> = ({ setActiveView }) => {
82
+ const [status, setStatus] = useState<AppStatus>(AppStatus.IDLE);
83
+ const [image, setImage] = useState<{ preview: string; base64: string } | null>(null);
84
+ const [blueprint, setBlueprint] = useState<StylingBlueprint | null>(null);
85
+ const [error, setError] = useState<string>('');
86
+ const [viewBox, setViewBox] = useState({ width: 0, height: 0 });
87
+ const [scale, setScale] = useState({ x: 1, y: 1 });
88
+ const fileInputRef = useRef<HTMLInputElement>(null);
89
+ const imageContainerRef = useRef<HTMLDivElement>(null);
90
+ const aiConfigured = isAIConfigured();
91
+
92
+ useLayoutEffect(() => {
93
+ if (image && blueprint && imageContainerRef.current) {
94
+ const { clientWidth, clientHeight } = imageContainerRef.current;
95
+ setViewBox({ width: clientWidth, height: clientHeight });
96
+ setScale({
97
+ x: clientWidth / blueprint.canvas.width,
98
+ y: clientHeight / blueprint.canvas.height
99
+ });
100
+ }
101
+ }, [image, blueprint]);
102
+
103
+
104
+ const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
105
+ const file = event.target.files?.[0];
106
+ if (file) {
107
+ if (file.size > 4 * 1024 * 1024) {
108
+ setError("File size exceeds 4MB. Please upload a smaller image.");
109
+ return;
110
+ }
111
+ const reader = new FileReader();
112
+ reader.onloadend = () => {
113
+ const base64String = (reader.result as string).split(',')[1];
114
+ setImage({ preview: reader.result as string, base64: base64String });
115
+ setError('');
116
+ setBlueprint(null);
117
+ setStatus(AppStatus.IDLE);
118
+ };
119
+ reader.onerror = () => setError("Failed to read the file.");
120
+ reader.readAsDataURL(file);
121
+ }
122
+ };
123
+
124
+ const handleGenerate = async () => {
125
+ if (!image) {
126
+ setError("Please upload an image first.");
127
+ return;
128
+ }
129
+
130
+ setStatus(AppStatus.ANALYZING);
131
+ setError('');
132
+ setBlueprint(null);
133
+
134
+ try {
135
+ const wiringPrompt = `Generate a styling blueprint focusing only on wiring. Show the ideal placement and direction for applying wire to shape the primary and secondary branches. Use the WIRE_DIRECTION annotation type. Provide a concise summary.`;
136
+ const result = await generateStylingBlueprint(image.base64, wiringPrompt);
137
+ if (result) {
138
+ setBlueprint(result);
139
+ setStatus(AppStatus.SUCCESS);
140
+ } else {
141
+ throw new Error('Failed to generate wiring guide. The AI may be busy or the image unclear. Please try again.');
142
+ }
143
+ } catch (e: any) {
144
+ setError(e.message);
145
+ setStatus(AppStatus.ERROR);
146
+ }
147
+ };
148
+
149
+ return (
150
+ <div className="space-y-8 max-w-7xl mx-auto">
151
+ <header className="text-center">
152
+ <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
153
+ <SnailIcon className="w-8 h-8 text-blue-600" />
154
+ AI Wiring Guide
155
+ </h2>
156
+ <p className="mt-4 text-lg leading-8 text-stone-600">
157
+ Visualize the perfect wiring paths for your bonsai before you touch a single wire.
158
+ </p>
159
+ </header>
160
+
161
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
162
+ {/* Left Column: Controls */}
163
+ <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-4 lg:col-span-1">
164
+ <div>
165
+ <label className="block text-sm font-medium text-stone-900">1. Upload Photo</label>
166
+ <div onClick={() => fileInputRef.current?.click()} className="mt-1 flex justify-center p-4 rounded-lg border-2 border-dashed border-stone-300 hover:border-blue-600 transition-colors cursor-pointer">
167
+ <div className="text-center">
168
+ {image ? <p className="text-green-700 font-semibold">Image Loaded!</p> : <UploadCloudIcon className="mx-auto h-10 w-10 text-stone-400" />}
169
+ <p className="mt-1 text-sm text-stone-600">{image ? 'Click to change image' : 'Click to upload'}</p>
170
+ </div>
171
+ <input ref={fileInputRef} type="file" className="sr-only" onChange={handleFileChange} accept="image/png, image/jpeg" />
172
+ </div>
173
+ </div>
174
+
175
+ <button onClick={handleGenerate} disabled={status === AppStatus.ANALYZING || !image || !aiConfigured} className="w-full flex items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 disabled:bg-stone-400 disabled:cursor-not-allowed">
176
+ <SparklesIcon className="w-5 h-5"/> {status === AppStatus.ANALYZING ? 'Generating Guide...' : '2. Generate Wiring Guide'}
177
+ </button>
178
+ {error && <p className="text-sm text-red-600 mt-2 bg-red-50 p-3 rounded-md">{error}</p>}
179
+ {!aiConfigured && (
180
+ <div className="mt-4 p-3 bg-yellow-50 text-yellow-800 rounded-lg border border-yellow-200 text-center">
181
+ <p className="text-sm">
182
+ Please set your Gemini API key in the{' '}
183
+ <button onClick={() => setActiveView('settings')} className="font-bold underline hover:text-yellow-900">
184
+ Settings page
185
+ </button>
186
+ {' '}to enable this feature.
187
+ </p>
188
+ </div>
189
+ )}
190
+ </div>
191
+
192
+ {/* Right Column: Canvas */}
193
+ <div className="bg-white p-4 rounded-xl shadow-lg border border-stone-200 lg:col-span-2">
194
+ <div ref={imageContainerRef} className="relative w-full aspect-w-4 aspect-h-3 bg-stone-100 rounded-lg">
195
+ {image ? (
196
+ <>
197
+ <img src={image.preview} alt="Bonsai" className="w-full h-full object-contain rounded-lg" />
198
+ {blueprint && (
199
+ <svg
200
+ className="absolute top-0 left-0 w-full h-full"
201
+ viewBox={`0 0 ${viewBox.width} ${viewBox.height}`}
202
+ xmlns="http://www.w3.org/2000/svg"
203
+ >
204
+ <defs>
205
+ <marker id="arrow-head-blue" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
206
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#3b82f6" />
207
+ </marker>
208
+ <marker id="arrow-head-orange" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
209
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#f97316" />
210
+ </marker>
211
+ </defs>
212
+ {blueprint.annotations.map((anno, i) => (
213
+ <SvgAnnotation key={i} annotation={anno} scale={scale} />
214
+ ))}
215
+ </svg>
216
+ )}
217
+ </>
218
+ ) : (
219
+ <div className="flex items-center justify-center h-full">
220
+ <p className="text-stone-500">Your image and guide will appear here</p>
221
+ </div>
222
+ )}
223
+ {status === AppStatus.ANALYZING && <div className="absolute inset-0 flex items-center justify-center bg-white/75"><Spinner text="Yuki is planning the wires..." /></div>}
224
+ </div>
225
+ {blueprint && status === AppStatus.SUCCESS && (
226
+ <div className="mt-4 p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg">
227
+ <h4 className="font-bold text-blue-800">Yuki's Summary</h4>
228
+ <p className="text-sm text-blue-700 mt-1">{blueprint.summary}</p>
229
+ </div>
230
+ )}
231
+ </div>
232
+ </div>
233
+ </div>
234
+ );
235
+ };
236
+
237
+ export default WiringGuideView;
vite.config.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'path';
2
+ import { defineConfig, loadEnv } from 'vite';
3
+
4
+ export default defineConfig(({ mode }) => {
5
+ const env = loadEnv(mode, '.', '');
6
+ return {
7
+ define: {
8
+ 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
9
+ 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
10
+ },
11
+ resolve: {
12
+ alias: {
13
+ '@': path.resolve(__dirname, '.'),
14
+ }
15
+ }
16
+ };
17
+ });