feat: Initialize GreenLens project with core dependencies and structure
Sets up the project using Vite, React, and TypeScript. Includes initial configuration for Tailwind CSS, Gemini API integration, and local storage management. Defines basic types for plant data and UI elements. The README is updated with local development instructions.
This commit is contained in:
parent
85960de05f
commit
024eec6686
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
@ -0,0 +1,680 @@
|
|||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Tab, Plant, IdentificationResult, Language } from './types';
|
||||
import { StorageService } from './services/storageService';
|
||||
import { PlantRecognitionService } from './services/plantRecognitionService';
|
||||
import { PlantDatabaseService } from './services/plantDatabaseService';
|
||||
import { getTranslation } from './utils/translations';
|
||||
import { TabBar } from './components/TabBar';
|
||||
import { PlantCard } from './components/PlantCard';
|
||||
import { PlantSkeleton } from './components/PlantSkeleton';
|
||||
import { ResultCard } from './components/ResultCard';
|
||||
import { PlantDetail } from './components/PlantDetail';
|
||||
import { Toast } from './components/Toast';
|
||||
import { Camera, Image as ImageIcon, HelpCircle, X, Settings as SettingsIcon, ScanLine, Leaf, Plus, Zap, Search, ArrowRight, ArrowLeft, Globe, ChevronDown, ChevronUp, Check, Cpu, BookOpen } from 'lucide-react';
|
||||
|
||||
const generateId = () => Math.random().toString(36).substr(2, 9);
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<Tab>(Tab.HOME);
|
||||
const [plants, setPlants] = useState<Plant[]>([]);
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const [language, setLanguage] = useState<Language>('de');
|
||||
const [isLoadingPlants, setIsLoadingPlants] = useState(true);
|
||||
|
||||
// Search State
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Lexicon State
|
||||
const [isLexiconOpen, setIsLexiconOpen] = useState(false);
|
||||
const [lexiconSearchQuery, setLexiconSearchQuery] = useState('');
|
||||
|
||||
// Settings State
|
||||
const [isLanguageDropdownOpen, setIsLanguageDropdownOpen] = useState(false);
|
||||
|
||||
// Scanner Modal State
|
||||
const [isScannerOpen, setIsScannerOpen] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
|
||||
// Analysis State
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [analysisProgress, setAnalysisProgress] = useState(0);
|
||||
const [analysisResult, setAnalysisResult] = useState<IdentificationResult | null>(null);
|
||||
|
||||
// Detail State
|
||||
const [selectedPlant, setSelectedPlant] = useState<Plant | null>(null);
|
||||
|
||||
// Toast State
|
||||
const [toast, setToast] = useState({ message: '', visible: false });
|
||||
|
||||
// Refs
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Derived state for translations
|
||||
const t = getTranslation(language);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setIsLoadingPlants(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
setPlants(StorageService.getPlants());
|
||||
setLanguage(StorageService.getLanguage());
|
||||
setIsLoadingPlants(false);
|
||||
};
|
||||
|
||||
loadData();
|
||||
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
setIsDarkMode(true);
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
setIsDarkMode(!isDarkMode);
|
||||
if (!isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
|
||||
const changeLanguage = (lang: Language) => {
|
||||
setLanguage(lang);
|
||||
StorageService.saveLanguage(lang);
|
||||
setIsLanguageDropdownOpen(false);
|
||||
};
|
||||
|
||||
const handleImageSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64String = reader.result as string;
|
||||
setSelectedImage(base64String);
|
||||
analyzeImage(base64String);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const analyzeImage = async (imageUri: string) => {
|
||||
setIsAnalyzing(true);
|
||||
setAnalysisProgress(0);
|
||||
setAnalysisResult(null);
|
||||
|
||||
// Simulate realistic progress
|
||||
const progressInterval = setInterval(() => {
|
||||
setAnalysisProgress(prev => {
|
||||
// Fast start
|
||||
if (prev < 30) return prev + Math.random() * 8;
|
||||
// Slower middle (processing)
|
||||
if (prev < 70) return prev + Math.random() * 2;
|
||||
// Stall at end (waiting for API)
|
||||
if (prev < 90) return prev + 0.5;
|
||||
return prev;
|
||||
});
|
||||
}, 150);
|
||||
|
||||
try {
|
||||
// Pass the current language to the service
|
||||
const result = await PlantRecognitionService.identify(imageUri, language);
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setAnalysisProgress(100);
|
||||
|
||||
// Short delay to allow user to see 100% completion
|
||||
setTimeout(() => {
|
||||
setAnalysisResult(result);
|
||||
setIsAnalyzing(false);
|
||||
}, 500);
|
||||
|
||||
} catch (error) {
|
||||
clearInterval(progressInterval);
|
||||
console.error("Analysis failed", error);
|
||||
alert("Fehler bei der Analyse.");
|
||||
setSelectedImage(null);
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showToast = (message: string) => {
|
||||
setToast({ message, visible: true });
|
||||
};
|
||||
|
||||
const hideToast = () => {
|
||||
setToast(prev => ({ ...prev, visible: false }));
|
||||
};
|
||||
|
||||
const savePlant = () => {
|
||||
if (analysisResult && selectedImage) {
|
||||
const now = new Date().toISOString();
|
||||
const newPlant: Plant = {
|
||||
id: generateId(),
|
||||
name: analysisResult.name,
|
||||
botanicalName: analysisResult.botanicalName,
|
||||
imageUri: selectedImage,
|
||||
dateAdded: now,
|
||||
careInfo: analysisResult.careInfo,
|
||||
lastWatered: now,
|
||||
wateringHistory: [now], // Initialize history with the creation date/first watering
|
||||
description: analysisResult.description,
|
||||
notificationsEnabled: false // Default off
|
||||
};
|
||||
|
||||
StorageService.savePlant(newPlant);
|
||||
setPlants(StorageService.getPlants());
|
||||
closeScanner();
|
||||
|
||||
// Also close lexicon if open
|
||||
setIsLexiconOpen(false);
|
||||
|
||||
showToast(t.plantAddedSuccess);
|
||||
}
|
||||
};
|
||||
|
||||
const closeScanner = () => {
|
||||
setIsScannerOpen(false);
|
||||
setSelectedImage(null);
|
||||
setAnalysisResult(null);
|
||||
setIsAnalyzing(false);
|
||||
setAnalysisProgress(0);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const openScanner = () => {
|
||||
setIsScannerOpen(true);
|
||||
};
|
||||
|
||||
const handlePlantClick = (plant: Plant) => {
|
||||
setSelectedPlant(plant);
|
||||
};
|
||||
|
||||
const closeDetail = () => {
|
||||
setSelectedPlant(null);
|
||||
};
|
||||
|
||||
const handleDeletePlant = (id: string) => {
|
||||
StorageService.deletePlant(id);
|
||||
setPlants(prev => prev.filter(p => p.id !== id));
|
||||
closeDetail();
|
||||
showToast(t.plantDeleted);
|
||||
};
|
||||
|
||||
const handleUpdatePlant = (updatedPlant: Plant) => {
|
||||
StorageService.updatePlant(updatedPlant);
|
||||
setPlants(prev => prev.map(p => p.id === updatedPlant.id ? updatedPlant : p));
|
||||
setSelectedPlant(updatedPlant);
|
||||
showToast(t.wateredSuccess);
|
||||
};
|
||||
|
||||
// Lexicon Handling
|
||||
const handleLexiconItemClick = (item: any) => {
|
||||
// We treat this like a "Scan Result" for simplicity, reusing the ResultCard
|
||||
setAnalysisResult(item);
|
||||
setSelectedImage(item.imageUri);
|
||||
// Since ResultCard is rendered conditionally based on analysisResult && selectedImage,
|
||||
// we need to make sure the view is visible.
|
||||
// We will render ResultCard inside the Lexicon view if selected.
|
||||
};
|
||||
|
||||
const closeLexiconResult = () => {
|
||||
setAnalysisResult(null);
|
||||
setSelectedImage(null);
|
||||
};
|
||||
|
||||
|
||||
// --- SCREENS ---
|
||||
|
||||
const renderHome = () => (
|
||||
<div className="pt-8 pb-24 px-6 min-h-screen bg-stone-50 dark:bg-stone-950">
|
||||
<header className="mb-6 flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{t.myPlants}</h1>
|
||||
<button onClick={() => setActiveTab(Tab.SETTINGS)}>
|
||||
<SettingsIcon size={24} className="text-stone-900 dark:text-stone-100" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex space-x-3 mb-6 overflow-x-auto no-scrollbar">
|
||||
<button className="flex items-center space-x-2 px-4 py-2 bg-white dark:bg-stone-900 rounded-full shadow-sm border border-stone-100 dark:border-stone-800 flex-shrink-0">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span className="text-xs font-bold text-stone-800 dark:text-stone-200">{t.allGood}</span>
|
||||
</button>
|
||||
<button className="flex items-center space-x-2 px-4 py-2 bg-stone-100 dark:bg-stone-900/50 rounded-full text-stone-400 flex-shrink-0">
|
||||
<span className="text-xs font-medium">{t.toWater} (0)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoadingPlants ? (
|
||||
<div className="grid grid-cols-2 gap-4 animate-in fade-in duration-500">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<PlantSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : plants.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center opacity-60 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<Leaf size={64} className="text-stone-300 dark:text-stone-700 mb-4" />
|
||||
<p className="text-lg font-medium text-stone-600 dark:text-stone-400">{t.noPlants}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 animate-in fade-in duration-500">
|
||||
{plants.map(plant => (
|
||||
<PlantCard key={plant.id} plant={plant} onClick={() => handlePlantClick(plant)} t={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FAB */}
|
||||
<button
|
||||
onClick={openScanner}
|
||||
className="fixed bottom-24 right-6 w-14 h-14 bg-primary-500 hover:bg-primary-600 rounded-full shadow-lg shadow-primary-500/40 flex items-center justify-center text-white z-30 transition-transform active:scale-90"
|
||||
>
|
||||
<Camera size={26} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSearch = () => {
|
||||
const filteredPlants = plants.filter(p =>
|
||||
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.botanicalName.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const categories = [
|
||||
{ name: t.catCareEasy, color: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" },
|
||||
{ name: t.catSucculents, color: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" },
|
||||
{ name: t.catLowLight, color: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400" },
|
||||
{ name: t.catPetFriendly, color: "bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400" },
|
||||
{ name: t.catAirPurifier, color: "bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400" },
|
||||
{ name: t.catFlowering, color: "bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-900/30 dark:text-fuchsia-400" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="pt-8 pb-24 px-6 min-h-screen bg-stone-50 dark:bg-stone-950">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-stone-900 dark:text-stone-100 mb-6">{t.searchTitle}</h1>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-3.5 text-stone-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t.searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-xl py-3 pl-12 pr-4 text-stone-900 dark:text-stone-100 focus:outline-none focus:ring-2 focus:ring-primary-500/50 placeholder:text-stone-400"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-3.5 text-stone-400 hover:text-stone-600 dark:hover:text-stone-200"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{searchQuery ? (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-stone-500 dark:text-stone-400 uppercase tracking-wider mb-4">
|
||||
{filteredPlants.length} {t.resultsInPlants}
|
||||
</h2>
|
||||
{filteredPlants.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{filteredPlants.map(plant => (
|
||||
<PlantCard key={plant.id} plant={plant} onClick={() => handlePlantClick(plant)} t={t} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-stone-500 dark:text-stone-400">{t.noResults}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<h2 className="text-lg font-bold text-stone-900 dark:text-stone-100 mb-4">{t.categories}</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.name}
|
||||
className={`p-4 rounded-xl text-left font-medium transition-transform active:scale-95 flex justify-between items-center group ${cat.color}`}
|
||||
>
|
||||
<span>{cat.name}</span>
|
||||
<ArrowRight size={16} className="opacity-0 group-hover:opacity-100 transition-opacity transform group-hover:translate-x-1" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => setIsLexiconOpen(true)}
|
||||
className="mt-8 p-6 bg-gradient-to-br from-primary-600 to-primary-800 rounded-2xl text-white shadow-lg relative overflow-hidden cursor-pointer active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<div className="relative z-10">
|
||||
<h3 className="text-xl font-serif font-bold mb-2 flex items-center">
|
||||
<BookOpen size={20} className="mr-2" />
|
||||
{t.lexiconTitle}
|
||||
</h3>
|
||||
<p className="text-primary-100 text-sm mb-4">{t.lexiconDesc}</p>
|
||||
<span className="inline-block bg-white/20 px-3 py-1 rounded-full text-xs font-bold backdrop-blur-sm">{t.browseLexicon}</span>
|
||||
</div>
|
||||
<Leaf size={120} className="absolute -bottom-6 -right-6 text-white/10 rotate-12" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLexicon = () => {
|
||||
if (!isLexiconOpen) return null;
|
||||
|
||||
// If we have a selected item from Lexicon, show ResultCard (Detail View)
|
||||
if (analysisResult && selectedImage) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] bg-stone-50 dark:bg-black">
|
||||
<ResultCard
|
||||
result={analysisResult}
|
||||
imageUri={selectedImage}
|
||||
onSave={savePlant}
|
||||
onClose={closeLexiconResult}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const lexiconPlants = PlantDatabaseService.searchPlants(lexiconSearchQuery, language);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-stone-50 dark:bg-stone-950 flex flex-col animate-in slide-in-from-bottom duration-300">
|
||||
{/* Header */}
|
||||
<div className="p-6 bg-white dark:bg-stone-900 border-b border-stone-200 dark:border-stone-800 flex items-center space-x-4 sticky top-0 z-10">
|
||||
<button onClick={() => setIsLexiconOpen(false)} className="p-2 -ml-2 text-stone-500 hover:text-stone-900 dark:hover:text-stone-100">
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<h1 className="text-xl font-bold font-serif text-stone-900 dark:text-stone-100">{t.lexiconTitle}</h1>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 no-scrollbar">
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-6">
|
||||
<Search className="absolute left-4 top-3.5 text-stone-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t.lexiconSearchPlaceholder}
|
||||
value={lexiconSearchQuery}
|
||||
onChange={(e) => setLexiconSearchQuery(e.target.value)}
|
||||
className="w-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-xl py-3 pl-12 pr-4 text-stone-900 dark:text-stone-100 focus:outline-none focus:ring-2 focus:ring-primary-500/50 placeholder:text-stone-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grid - NOW 3 COLUMNS */}
|
||||
<div className="grid grid-cols-3 gap-3 pb-20">
|
||||
{lexiconPlants.map((plant, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleLexiconItemClick(plant)}
|
||||
className="text-left bg-white dark:bg-stone-900 rounded-2xl overflow-hidden shadow-sm border border-stone-100 dark:border-stone-800 group active:scale-[0.98] transition-all"
|
||||
>
|
||||
<div className="aspect-square relative">
|
||||
<img src={plant.imageUri} className="w-full h-full object-cover" loading="lazy" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-60"></div>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<h3 className="text-sm font-bold text-stone-900 dark:text-stone-100 truncate">{plant.name}</h3>
|
||||
<p className="text-[10px] text-stone-500 dark:text-stone-400 italic truncate">{plant.botanicalName}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{lexiconPlants.length === 0 && (
|
||||
<div className="col-span-3 text-center py-10 text-stone-400">
|
||||
{t.noResults}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSettings = () => {
|
||||
const languages: { code: Language; label: string }[] = [
|
||||
{ code: 'de', label: 'Deutsch' },
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'es', label: 'Español' }
|
||||
];
|
||||
|
||||
const currentLangLabel = languages.find(l => l.code === language)?.label;
|
||||
|
||||
return (
|
||||
<div className="pt-12 px-6 h-screen bg-stone-50 dark:bg-stone-950 pb-24">
|
||||
<h1 className="text-2xl font-bold text-stone-900 dark:text-stone-100 mb-8">{t.settingsTitle}</h1>
|
||||
|
||||
{/* Dark Mode Settings */}
|
||||
<div className="bg-white dark:bg-stone-900 p-4 rounded-2xl shadow-sm border border-stone-100 dark:border-stone-800 flex justify-between items-center mb-4">
|
||||
<span className="font-medium text-stone-900 dark:text-stone-200">{t.darkMode}</span>
|
||||
<button onClick={toggleDarkMode} className={`w-12 h-7 rounded-full relative ${isDarkMode ? 'bg-primary-600' : 'bg-stone-300'}`}>
|
||||
<div className={`absolute top-1 w-5 h-5 bg-white rounded-full transition-all ${isDarkMode ? 'left-6' : 'left-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Language Settings (Dropdown Style) */}
|
||||
<div className="bg-white dark:bg-stone-900 p-4 rounded-2xl shadow-sm border border-stone-100 dark:border-stone-800 transition-all duration-300">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => setIsLanguageDropdownOpen(!isLanguageDropdownOpen)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Globe size={18} className="text-stone-400" />
|
||||
<span className="font-medium text-stone-900 dark:text-stone-200">{t.language}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-bold text-primary-600 dark:text-primary-400">{currentLangLabel}</span>
|
||||
{isLanguageDropdownOpen ? <ChevronUp size={16} className="text-stone-400" /> : <ChevronDown size={16} className="text-stone-400" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dropdown Content */}
|
||||
{isLanguageDropdownOpen && (
|
||||
<div className="mt-4 pt-4 border-t border-stone-100 dark:border-stone-800 animate-in slide-in-from-top-2">
|
||||
<div className="space-y-2">
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => changeLanguage(lang.code)}
|
||||
className={`w-full flex justify-between items-center p-3 rounded-xl transition-colors ${
|
||||
language === lang.code
|
||||
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300'
|
||||
: 'hover:bg-stone-50 dark:hover:bg-stone-800 text-stone-600 dark:text-stone-300'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-sm">{lang.label}</span>
|
||||
{language === lang.code && <Check size={16} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderScannerModal = () => {
|
||||
if (!isScannerOpen) return null;
|
||||
|
||||
// 1. Result View
|
||||
if (analysisResult && selectedImage) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-stone-50 dark:bg-black">
|
||||
<ResultCard
|
||||
result={analysisResult}
|
||||
imageUri={selectedImage}
|
||||
onSave={savePlant}
|
||||
onClose={closeScanner}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Scanner View
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-stone-900 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 p-6 flex justify-between items-center z-10 text-white">
|
||||
<button onClick={closeScanner}><X size={28} /></button>
|
||||
<span className="font-medium text-lg">{t.scanner}</span>
|
||||
<button><Zap size={24} className="text-white/50" /></button>
|
||||
</div>
|
||||
|
||||
{/* Main Camera Area */}
|
||||
<div className="flex-1 relative overflow-hidden flex items-center justify-center">
|
||||
{selectedImage ? (
|
||||
<img src={selectedImage} className="absolute inset-0 w-full h-full object-cover opacity-50 blur-sm" />
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/60 via-transparent to-black/60 pointer-events-none"></div>
|
||||
)}
|
||||
|
||||
{/* Background Grid */}
|
||||
<div className="absolute inset-0 opacity-10 pointer-events-none"
|
||||
style={{ backgroundImage: 'radial-gradient(#ffffff 1px, transparent 1px)', backgroundSize: '32px 32px' }}>
|
||||
</div>
|
||||
|
||||
{/* Scan Frame */}
|
||||
<div className="w-64 h-80 border-[3px] border-white/30 rounded-[2rem] relative flex items-center justify-center overflow-hidden backdrop-blur-[2px]">
|
||||
|
||||
{/* SHOW SELECTED IMAGE IN FRAME */}
|
||||
{selectedImage && (
|
||||
<img
|
||||
src={selectedImage}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
alt="Scan preview"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="absolute top-4 left-4 w-6 h-6 border-t-4 border-l-4 border-white rounded-tl-xl z-10"></div>
|
||||
<div className="absolute top-4 right-4 w-6 h-6 border-t-4 border-r-4 border-white rounded-tr-xl z-10"></div>
|
||||
<div className="absolute bottom-4 left-4 w-6 h-6 border-b-4 border-l-4 border-white rounded-bl-xl z-10"></div>
|
||||
<div className="absolute bottom-4 right-4 w-6 h-6 border-b-4 border-r-4 border-white rounded-br-xl z-10"></div>
|
||||
|
||||
{/* Laser Line */}
|
||||
{isAnalyzing || !selectedImage ? (
|
||||
<div className="absolute left-0 right-0 h-0.5 bg-primary-400 shadow-[0_0_15px_rgba(74,222,128,0.8)] animate-scan z-20"></div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analyzing Sheet Overlay - Loading Animation */}
|
||||
{isAnalyzing && (
|
||||
<div className="absolute bottom-32 left-4 right-4 bg-white dark:bg-stone-800 rounded-2xl p-4 shadow-xl flex flex-col animate-in slide-in-from-bottom-5 z-30">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-bold text-stone-900 dark:text-white text-sm transition-all">
|
||||
{analysisProgress < 100 ? t.analyzing : t.result}
|
||||
</span>
|
||||
<span className="font-mono text-xs font-bold text-stone-500 dark:text-stone-400">
|
||||
{Math.round(analysisProgress)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="h-2 w-full bg-stone-100 dark:bg-stone-700 rounded-full overflow-hidden mb-3 relative">
|
||||
<div
|
||||
className="h-full bg-primary-500 rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${analysisProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* Stage Indicators */}
|
||||
<div className="flex justify-between items-center text-[10px] text-stone-400 font-bold uppercase tracking-wider">
|
||||
<div className="flex items-center">
|
||||
<div className={`w-2 h-2 rounded-full mr-1.5 ${analysisProgress < 100 ? 'bg-amber-400 animate-pulse' : 'bg-green-500'}`}></div>
|
||||
{t.localProcessing}
|
||||
</div>
|
||||
<span className="opacity-70 transition-opacity duration-300 text-right">
|
||||
{analysisProgress < 30 ? t.scanStage1 : analysisProgress < 75 ? t.scanStage2 : t.scanStage3}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<div className="bg-white rounded-t-[2.5rem] px-8 pt-8 pb-12 flex justify-between items-center">
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
<button onClick={() => fileInputRef.current?.click()} className="p-4 bg-stone-100 rounded-2xl text-stone-600 active:scale-95 transition-transform">
|
||||
<ImageIcon size={24} />
|
||||
</button>
|
||||
<span className="text-xs font-medium text-stone-500">{t.gallery}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-20 h-20 bg-primary-500 rounded-full border-4 border-stone-100 shadow-xl flex items-center justify-center active:scale-95 transition-transform"
|
||||
>
|
||||
<div className="w-16 h-16 bg-white/20 rounded-full"></div>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
<button className="p-4 bg-stone-50 rounded-2xl text-stone-400">
|
||||
<HelpCircle size={24} />
|
||||
</button>
|
||||
<span className="text-xs font-medium text-stone-300">{t.help}</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImageSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto min-h-screen relative overflow-hidden bg-stone-50 dark:bg-stone-950 shadow-2xl">
|
||||
<div className="h-full overflow-y-auto no-scrollbar">
|
||||
{activeTab === Tab.HOME && renderHome()}
|
||||
{activeTab === Tab.SETTINGS && renderSettings()}
|
||||
{activeTab === Tab.SEARCH && renderSearch()}
|
||||
</div>
|
||||
|
||||
<TabBar
|
||||
currentTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
labels={{ home: t.tabPlants, search: t.tabSearch, settings: t.tabProfile }}
|
||||
/>
|
||||
|
||||
{/* Modal Layer for Detail View */}
|
||||
{selectedPlant && (
|
||||
<PlantDetail
|
||||
plant={selectedPlant}
|
||||
onClose={closeDetail}
|
||||
onDelete={handleDeletePlant}
|
||||
onUpdate={handleUpdatePlant}
|
||||
t={t}
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal Layer for Scanner */}
|
||||
{renderScannerModal()}
|
||||
|
||||
{/* Lexicon Overlay */}
|
||||
{renderLexicon()}
|
||||
|
||||
{/* Toast Notification */}
|
||||
<Toast message={toast.message} isVisible={toast.visible} onClose={hideToast} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
25
README.md
25
README.md
|
|
@ -1,11 +1,20 @@
|
|||
<div align="center">
|
||||
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
|
||||
<h1>Built with AI Studio</h2>
|
||||
|
||||
<p>The fastest path from prompt to production with Gemini.</p>
|
||||
|
||||
<a href="https://aistudio.google.com/apps">Start building</a>
|
||||
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1cQZAaCmJNVx9ML_ZZV-F0gNCNhjSrgd8
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import React from 'react';
|
||||
import { Plant } from '../types';
|
||||
import { Droplets } from 'lucide-react';
|
||||
|
||||
interface PlantCardProps {
|
||||
plant: Plant;
|
||||
onClick: () => void;
|
||||
t: any; // Using any for simplicity with the dynamic translation object
|
||||
}
|
||||
|
||||
export const PlantCard: React.FC<PlantCardProps> = ({ plant, onClick, t }) => {
|
||||
const daysUntilWatering = plant.careInfo.waterIntervalDays;
|
||||
// Very basic check logic for MVP
|
||||
const isUrgent = daysUntilWatering <= 1;
|
||||
|
||||
const wateringText = isUrgent
|
||||
? t.waterToday
|
||||
: t.inXDays.replace('{0}', daysUntilWatering.toString());
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="relative aspect-[4/5] rounded-2xl overflow-hidden shadow-md group active:scale-[0.98] transition-transform w-full text-left"
|
||||
>
|
||||
<img
|
||||
src={plant.imageUri}
|
||||
alt={plant.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
{/* Gradient Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent" />
|
||||
|
||||
{/* Badge */}
|
||||
<div className="absolute top-3 left-3">
|
||||
<div className={`flex items-center space-x-1.5 px-2.5 py-1 rounded-full backdrop-blur-md ${
|
||||
isUrgent
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-black/40 text-stone-200 border border-white/10'
|
||||
}`}>
|
||||
<Droplets size={10} className="fill-current" />
|
||||
<span className="text-[10px] font-bold">
|
||||
{wateringText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="absolute bottom-4 left-3 right-3">
|
||||
<h3 className="text-white font-bold text-lg leading-tight font-serif mb-0.5 shadow-sm">
|
||||
{plant.name}
|
||||
</h3>
|
||||
<p className="text-stone-300 text-xs truncate">
|
||||
{plant.botanicalName}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,302 @@
|
|||
|
||||
import React, { useState } from 'react';
|
||||
import { Plant, Language } from '../types';
|
||||
import { Droplets, Sun, Thermometer, ArrowLeft, Calendar, Trash2, Share2, Edit2, AlertCircle, Check, Clock, Bell, BellOff } from 'lucide-react';
|
||||
|
||||
interface PlantDetailProps {
|
||||
plant: Plant;
|
||||
onClose: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
onUpdate: (plant: Plant) => void;
|
||||
t: any;
|
||||
language: Language;
|
||||
}
|
||||
|
||||
export const PlantDetail: React.FC<PlantDetailProps> = ({ plant, onClose, onDelete, onUpdate, t, language }) => {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
// Map internal language codes to locale strings for Date
|
||||
const localeMap: Record<string, string> = {
|
||||
de: 'de-DE',
|
||||
en: 'en-US',
|
||||
es: 'es-ES'
|
||||
};
|
||||
|
||||
const formattedAddedDate = new Date(plant.dateAdded).toLocaleDateString(localeMap[language] || 'de-DE');
|
||||
const formattedWateredDate = new Date(plant.lastWatered).toLocaleDateString(localeMap[language] || 'de-DE');
|
||||
|
||||
// Calculate next watering date
|
||||
const lastWateredObj = new Date(plant.lastWatered);
|
||||
const nextWateringDate = new Date(lastWateredObj);
|
||||
nextWateringDate.setDate(lastWateredObj.getDate() + plant.careInfo.waterIntervalDays);
|
||||
|
||||
const formattedNextWatering = nextWateringDate.toLocaleDateString(localeMap[language] || 'de-DE', { weekday: 'long', day: 'numeric', month: 'numeric' });
|
||||
const nextWateringText = t.nextWatering.replace('{0}', formattedNextWatering);
|
||||
const lastWateredText = t.lastWateredDate.replace('{0}', formattedWateredDate);
|
||||
|
||||
// Check if watered today
|
||||
const isWateredToday = new Date(plant.lastWatered).toDateString() === new Date().toDateString();
|
||||
|
||||
const handleWaterPlant = () => {
|
||||
const now = new Date().toISOString();
|
||||
// Update history: add new date to the beginning, keep last 10 entries max
|
||||
const currentHistory = plant.wateringHistory || [];
|
||||
const newHistory = [now, ...currentHistory].slice(0, 10);
|
||||
|
||||
const updatedPlant = {
|
||||
...plant,
|
||||
lastWatered: now,
|
||||
wateringHistory: newHistory
|
||||
};
|
||||
onUpdate(updatedPlant);
|
||||
};
|
||||
|
||||
const toggleReminder = async () => {
|
||||
const newValue = !plant.notificationsEnabled;
|
||||
|
||||
if (newValue) {
|
||||
// Request permission if enabling
|
||||
if (!('Notification' in window)) {
|
||||
alert("Notifications are not supported by this browser.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
onUpdate({ ...plant, notificationsEnabled: true });
|
||||
} else if (Notification.permission !== 'denied') {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission === 'granted') {
|
||||
onUpdate({ ...plant, notificationsEnabled: true });
|
||||
}
|
||||
} else {
|
||||
alert(t.reminderPermissionNeeded);
|
||||
}
|
||||
} else {
|
||||
// Disabling
|
||||
onUpdate({ ...plant, notificationsEnabled: false });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
onDelete(plant.id);
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col h-full bg-stone-50 dark:bg-stone-950 overflow-y-auto no-scrollbar animate-in slide-in-from-right duration-300">
|
||||
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-10 flex justify-between items-center p-6 text-stone-900 dark:text-white">
|
||||
<button onClick={onClose} className="bg-white/80 dark:bg-black/50 backdrop-blur-md p-2 rounded-full shadow-sm">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div className="flex space-x-2">
|
||||
<button className="bg-white/80 dark:bg-black/50 backdrop-blur-md p-2 rounded-full shadow-sm">
|
||||
<Share2 size={20} />
|
||||
</button>
|
||||
<button className="bg-white/80 dark:bg-black/50 backdrop-blur-md p-2 rounded-full shadow-sm">
|
||||
<Edit2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{/* Hero Image */}
|
||||
<div className="relative w-full aspect-[4/5] md:aspect-video rounded-b-[2.5rem] overflow-hidden shadow-lg mb-6">
|
||||
<img src={plant.imageUri} alt={plant.name} className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
|
||||
<div className="absolute bottom-8 left-6 right-6">
|
||||
<h1 className="text-3xl font-serif font-bold text-white leading-tight mb-1 shadow-sm">
|
||||
{plant.name}
|
||||
</h1>
|
||||
<p className="text-stone-200 italic text-sm">
|
||||
{plant.botanicalName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Container */}
|
||||
<div className="px-6 pb-24">
|
||||
{/* Added Date Info */}
|
||||
<div className="flex justify-between items-center text-xs text-stone-500 dark:text-stone-400 mb-6">
|
||||
<div className="flex items-center space-x-1.5 bg-white dark:bg-stone-900 px-3 py-1.5 rounded-full border border-stone-100 dark:border-stone-800">
|
||||
<Calendar size={12} />
|
||||
<span>{t.addedOn} {formattedAddedDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Action: Water */}
|
||||
<div className={`mb-3 p-4 rounded-2xl border flex justify-between items-center transition-colors ${
|
||||
isWateredToday
|
||||
? 'bg-green-50 dark:bg-green-900/10 border-green-100 dark:border-green-800/30'
|
||||
: 'bg-blue-50 dark:bg-blue-900/10 border-blue-100 dark:border-blue-800/30'
|
||||
}`}>
|
||||
<div>
|
||||
<span className="block text-xs text-stone-500 dark:text-stone-400 font-medium mb-0.5">{lastWateredText}</span>
|
||||
<span className={`block text-sm font-bold ${isWateredToday ? 'text-green-700 dark:text-green-300' : 'text-stone-900 dark:text-stone-200'}`}>
|
||||
{nextWateringText}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleWaterPlant}
|
||||
disabled={isWateredToday}
|
||||
className={`px-4 py-2.5 rounded-xl font-bold text-xs flex items-center shadow-lg transition-all ${
|
||||
isWateredToday
|
||||
? 'bg-green-500 text-white cursor-default shadow-green-500/30'
|
||||
: 'bg-blue-500 hover:bg-blue-600 active:scale-95 text-white shadow-blue-500/30'
|
||||
}`}
|
||||
>
|
||||
{isWateredToday ? (
|
||||
<>
|
||||
<Check size={14} className="mr-2" />
|
||||
{t.watered}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Droplets size={14} className="mr-2 fill-current" />
|
||||
{t.waterNow}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Reminder Toggle */}
|
||||
<div className="mb-8 flex items-center justify-between p-3 rounded-xl bg-white dark:bg-stone-900 border border-stone-100 dark:border-stone-800">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`p-2 rounded-full ${plant.notificationsEnabled ? 'bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-stone-100 text-stone-400 dark:bg-stone-800 dark:text-stone-500'}`}>
|
||||
{plant.notificationsEnabled ? <Bell size={18} /> : <BellOff size={18} />}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-stone-900 dark:text-stone-100">{t.reminder}</span>
|
||||
<span className="text-[10px] text-stone-500">{plant.notificationsEnabled ? t.reminderOn : t.reminderOff}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleReminder}
|
||||
className={`w-11 h-6 rounded-full relative transition-colors duration-300 ${plant.notificationsEnabled ? 'bg-primary-500' : 'bg-stone-300 dark:bg-stone-700'}`}
|
||||
>
|
||||
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-all duration-300 shadow-sm ${plant.notificationsEnabled ? 'left-6' : 'left-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="font-bold text-stone-900 dark:text-stone-100 mb-2">{t.aboutPlant}</h3>
|
||||
<p className="text-stone-600 dark:text-stone-300 text-sm leading-relaxed mb-8">
|
||||
{plant.description || t.noDescription}
|
||||
</p>
|
||||
|
||||
{/* Care Info */}
|
||||
<h3 className="font-bold text-stone-900 dark:text-stone-100 mb-4">{t.careTips}</h3>
|
||||
<div className="grid grid-cols-3 gap-3 mb-10">
|
||||
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center mb-2">
|
||||
<Droplets size={16} className="fill-current" />
|
||||
</div>
|
||||
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.water}</span>
|
||||
<span className="text-xs font-bold text-stone-800 dark:text-stone-200">
|
||||
{plant.careInfo.waterIntervalDays} {t.days || 'Tage'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
|
||||
<div className="w-8 h-8 rounded-full bg-amber-50 dark:bg-amber-900/20 text-amber-500 flex items-center justify-center mb-2">
|
||||
<Sun size={16} className="fill-current" />
|
||||
</div>
|
||||
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.light}</span>
|
||||
<span className="text-xs font-bold text-stone-800 dark:text-stone-200 truncate w-full">
|
||||
{plant.careInfo.light}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
|
||||
<div className="w-8 h-8 rounded-full bg-rose-50 dark:bg-rose-900/20 text-rose-500 flex items-center justify-center mb-2">
|
||||
<Thermometer size={16} className="fill-current" />
|
||||
</div>
|
||||
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.temp}</span>
|
||||
<span className="text-xs font-bold text-stone-800 dark:text-stone-200">
|
||||
{plant.careInfo.temp}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Watering History Section */}
|
||||
<h3 className="font-bold text-stone-900 dark:text-stone-100 mb-4">{t.wateringHistory}</h3>
|
||||
<div className="bg-white dark:bg-stone-900 rounded-2xl border border-stone-100 dark:border-stone-800 overflow-hidden mb-10 shadow-sm">
|
||||
{(!plant.wateringHistory || plant.wateringHistory.length === 0) ? (
|
||||
<div className="p-6 text-center text-stone-400 text-sm">
|
||||
<Clock size={24} className="mx-auto mb-2 opacity-50" />
|
||||
{t.noHistory}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-stone-100 dark:divide-stone-800">
|
||||
{plant.wateringHistory.slice(0, 5).map((dateStr, index) => (
|
||||
<li key={index} className="px-5 py-3 flex justify-between items-center group hover:bg-stone-50 dark:hover:bg-stone-800/50 transition-colors">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/10 text-blue-500 flex items-center justify-center">
|
||||
<Droplets size={14} className="fill-current" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||
{new Date(dateStr).toLocaleDateString(localeMap[language] || 'de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-stone-400 bg-stone-100 dark:bg-stone-800 px-2 py-1 rounded-md">
|
||||
{new Date(dateStr).toLocaleTimeString(localeMap[language] || 'de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="border-t border-stone-200 dark:border-stone-800 pt-6 flex justify-center">
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
className="flex items-center space-x-2 text-red-500 hover:text-red-600 px-4 py-2 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<span className="text-sm font-medium">{t.delete}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="absolute inset-0 z-[60] bg-black/40 backdrop-blur-sm flex items-center justify-center p-6 animate-in fade-in duration-200">
|
||||
<div className="bg-white dark:bg-stone-900 w-full max-w-sm rounded-2xl p-6 shadow-2xl scale-100 animate-in zoom-in-95 duration-200">
|
||||
<div className="w-12 h-12 bg-red-100 dark:bg-red-900/30 text-red-500 rounded-full flex items-center justify-center mb-4 mx-auto">
|
||||
<AlertCircle size={24} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-center text-stone-900 dark:text-white mb-2">{t.deleteConfirmTitle}</h3>
|
||||
<p className="text-stone-500 dark:text-stone-400 text-center text-sm mb-6 leading-relaxed">
|
||||
{t.deleteConfirmMessage}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={handleCancelDelete}
|
||||
className="py-3 px-4 rounded-xl bg-stone-100 dark:bg-stone-800 text-stone-700 dark:text-stone-300 font-bold text-sm"
|
||||
>
|
||||
{t.cancel}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmDelete}
|
||||
className="py-3 px-4 rounded-xl bg-red-500 text-white font-bold text-sm hover:bg-red-600"
|
||||
>
|
||||
{t.confirm}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
|
||||
export const PlantSkeleton: React.FC = () => {
|
||||
return (
|
||||
<div className="relative aspect-[4/5] rounded-2xl overflow-hidden bg-stone-200 dark:bg-stone-800 animate-pulse border border-stone-300 dark:border-stone-700/50">
|
||||
|
||||
{/* Badge Placeholder */}
|
||||
<div className="absolute top-3 left-3">
|
||||
<div className="h-6 w-20 bg-stone-300 dark:bg-stone-700 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Content Placeholder */}
|
||||
<div className="absolute bottom-4 left-3 right-3 space-y-2">
|
||||
{/* Title */}
|
||||
<div className="h-6 w-3/4 bg-stone-300 dark:bg-stone-700 rounded-md" />
|
||||
{/* Subtitle */}
|
||||
<div className="h-3 w-1/2 bg-stone-300 dark:bg-stone-700 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
|
||||
import React, { useState } from 'react';
|
||||
import { IdentificationResult } from '../types';
|
||||
import { Droplets, Sun, Thermometer, CheckCircle2, ArrowLeft, Share2 } from 'lucide-react';
|
||||
|
||||
interface ResultCardProps {
|
||||
result: IdentificationResult;
|
||||
imageUri: string;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
t: any;
|
||||
}
|
||||
|
||||
export const ResultCard: React.FC<ResultCardProps> = ({ result, imageUri, onSave, onClose, t }) => {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-stone-50 dark:bg-stone-950 overflow-y-auto no-scrollbar animate-in slide-in-from-right duration-300">
|
||||
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-10 flex justify-between items-center p-6 text-stone-900 dark:text-white">
|
||||
<button onClick={onClose} className="bg-white/80 dark:bg-black/50 backdrop-blur-md p-2 rounded-full shadow-sm">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<span className="font-bold text-sm bg-white/80 dark:bg-black/50 backdrop-blur-md px-3 py-1 rounded-full">{t.result}</span>
|
||||
<button className="bg-white/80 dark:bg-black/50 backdrop-blur-md p-2 rounded-full shadow-sm">
|
||||
<Share2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 pt-20">
|
||||
{/* Hero Image */}
|
||||
<div className="relative w-full aspect-[4/3] rounded-[2rem] overflow-hidden shadow-lg mb-6">
|
||||
<img src={imageUri} alt="Analyzed Plant" className="w-full h-full object-cover" />
|
||||
<div className="absolute bottom-4 left-4 bg-white/90 backdrop-blur-sm text-primary-700 px-3 py-1.5 rounded-full text-xs font-bold shadow-sm flex items-center">
|
||||
<CheckCircle2 size={14} className="mr-1.5 fill-primary-600 text-white" />
|
||||
{Math.round(result.confidence * 100)}% {t.match}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="px-2">
|
||||
<h1 className="text-3xl font-serif font-bold text-stone-900 dark:text-stone-100 leading-tight mb-1">
|
||||
{result.name}
|
||||
</h1>
|
||||
<p className="text-stone-400 dark:text-stone-500 italic text-sm mb-6">
|
||||
{result.botanicalName}
|
||||
</p>
|
||||
|
||||
<p className="text-stone-600 dark:text-stone-300 text-sm leading-relaxed mb-8">
|
||||
{result.description || t.noDescription}
|
||||
</p>
|
||||
|
||||
{/* Care Check */}
|
||||
<div className="flex justify-between items-end mb-4">
|
||||
<h3 className="font-bold text-stone-900 dark:text-stone-100">{t.careCheck}</h3>
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="text-[10px] font-bold text-primary-600 uppercase tracking-wide"
|
||||
>
|
||||
{showDetails ? t.hideDetails : t.showDetails}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 mb-8">
|
||||
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center mb-2">
|
||||
<Droplets size={16} className="fill-current" />
|
||||
</div>
|
||||
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.water}</span>
|
||||
<span className="text-xs font-bold text-stone-800 dark:text-stone-200">
|
||||
{result.careInfo.waterIntervalDays <= 7 ? t.waterModerate : t.waterLittle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
|
||||
<div className="w-8 h-8 rounded-full bg-amber-50 dark:bg-amber-900/20 text-amber-500 flex items-center justify-center mb-2">
|
||||
<Sun size={16} className="fill-current" />
|
||||
</div>
|
||||
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.light}</span>
|
||||
<span className="text-xs font-bold text-stone-800 dark:text-stone-200 truncate w-full">
|
||||
{result.careInfo.light}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
|
||||
<div className="w-8 h-8 rounded-full bg-rose-50 dark:bg-rose-900/20 text-rose-500 flex items-center justify-center mb-2">
|
||||
<Thermometer size={16} className="fill-current" />
|
||||
</div>
|
||||
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.temp}</span>
|
||||
<span className="text-xs font-bold text-stone-800 dark:text-stone-200">
|
||||
{result.careInfo.temp}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{showDetails && (
|
||||
<div className="mb-8 p-5 bg-stone-100 dark:bg-stone-900/50 rounded-2xl animate-in fade-in slide-in-from-top-2 border border-stone-200 dark:border-stone-800">
|
||||
<h4 className="font-bold text-xs mb-3 text-stone-700 dark:text-stone-300 uppercase tracking-wide">{t.detailedCare}</h4>
|
||||
<ul className="space-y-3 text-sm text-stone-600 dark:text-stone-300">
|
||||
<li className="flex items-start">
|
||||
<span className="mr-3 text-primary-500">•</span>
|
||||
<span>{t.careTextWater.replace('{0}', result.careInfo.waterIntervalDays.toString())}</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="mr-3 text-amber-500">•</span>
|
||||
<span>{t.careTextLight.replace('{0}', result.careInfo.light)}</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="mr-3 text-rose-500">•</span>
|
||||
<span>{t.careTextTemp.replace('{0}', result.careInfo.temp)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex items-center justify-center space-x-2 text-xs text-stone-400 mb-4">
|
||||
<div className="w-3 h-3 rounded-sm border border-stone-300 flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 bg-stone-400 rounded-[1px]"></div>
|
||||
</div>
|
||||
<span>{t.dataSavedLocally}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="w-full py-4 bg-primary-500 hover:bg-primary-600 text-black font-bold text-sm rounded-xl shadow-lg shadow-primary-500/30 active:scale-[0.98] transition-all flex items-center justify-center"
|
||||
>
|
||||
<div className="bg-black/20 rounded-full p-0.5 mr-2">
|
||||
<CheckCircle2 size={14} className="text-black" />
|
||||
</div>
|
||||
{t.addToPlants}
|
||||
</button>
|
||||
<div className="h-8"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import React from 'react';
|
||||
import { Tab } from '../types';
|
||||
import { LayoutGrid, Search, User } from 'lucide-react';
|
||||
|
||||
interface TabBarProps {
|
||||
currentTab: Tab;
|
||||
onTabChange: (tab: Tab) => void;
|
||||
labels: {
|
||||
home: string;
|
||||
search: string;
|
||||
settings: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const TabBar: React.FC<TabBarProps> = ({ currentTab, onTabChange, labels }) => {
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-stone-900 border-t border-stone-200 dark:border-stone-800 pb-safe pt-2 px-6 z-40">
|
||||
<div className="flex justify-between items-center h-16 max-w-sm mx-auto">
|
||||
<button
|
||||
onClick={() => onTabChange(Tab.HOME)}
|
||||
className={`flex flex-col items-center justify-center w-16 space-y-1.5 ${
|
||||
currentTab === Tab.HOME ? 'text-stone-900 dark:text-stone-100' : 'text-stone-400 dark:text-stone-600'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid size={24} strokeWidth={currentTab === Tab.HOME ? 2.5 : 2} />
|
||||
<span className="text-[10px] font-medium">{labels.home}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onTabChange(Tab.SEARCH)}
|
||||
className={`flex flex-col items-center justify-center w-16 space-y-1.5 ${
|
||||
currentTab === Tab.SEARCH ? 'text-stone-900 dark:text-stone-100' : 'text-stone-400 dark:text-stone-600'
|
||||
}`}
|
||||
>
|
||||
<Search size={24} strokeWidth={currentTab === Tab.SEARCH ? 2.5 : 2} />
|
||||
<span className="text-[10px] font-medium">{labels.search}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onTabChange(Tab.SETTINGS)}
|
||||
className={`flex flex-col items-center justify-center w-16 space-y-1.5 ${
|
||||
currentTab === Tab.SETTINGS ? 'text-stone-900 dark:text-stone-100' : 'text-stone-400 dark:text-stone-600'
|
||||
}`}
|
||||
>
|
||||
<User size={24} strokeWidth={currentTab === Tab.SETTINGS ? 2.5 : 2} />
|
||||
<span className="text-[10px] font-medium">{labels.settings}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface ToastProps {
|
||||
message: string;
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const Toast: React.FC<ToastProps> = ({ message, isVisible, onClose }) => {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
setShow(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShow(false);
|
||||
setTimeout(onClose, 300); // Wait for animation
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setShow(false);
|
||||
}
|
||||
}, [isVisible, onClose]);
|
||||
|
||||
if (!isVisible && !show) return null;
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-20 left-0 right-0 z-[70] flex justify-center pointer-events-none transition-all duration-300 transform ${show ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0'}`}>
|
||||
<div className="bg-stone-900 dark:bg-white text-white dark:text-stone-900 px-4 py-3 rounded-full shadow-lg flex items-center space-x-2">
|
||||
<CheckCircle2 size={18} className="text-green-500" />
|
||||
<span className="text-sm font-medium">{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>GreenLens</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#ecfdf5',
|
||||
100: '#d1fae5',
|
||||
200: '#a7f3d0',
|
||||
300: '#6ee7b7',
|
||||
400: '#34d399',
|
||||
500: '#10b981',
|
||||
600: '#059669',
|
||||
700: '#047857',
|
||||
800: '#065f46',
|
||||
900: '#064e3b',
|
||||
},
|
||||
stone: {
|
||||
850: '#1f1f1e',
|
||||
950: '#0c0c0c',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
serif: ['Merriweather', 'serif'],
|
||||
},
|
||||
animation: {
|
||||
'scan': 'scan 2s linear infinite',
|
||||
},
|
||||
keyframes: {
|
||||
scan: {
|
||||
'0%': { top: '0%' },
|
||||
'50%': { top: '100%' },
|
||||
'100%': { top: '0%' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react": "https://esm.sh/react@^19.2.4",
|
||||
"react-dom/": "https://esm.sh/react-dom@^19.2.4/",
|
||||
"react/": "https://esm.sh/react@^19.2.4/",
|
||||
"@google/genai": "https://esm.sh/@google/genai@^1.38.0",
|
||||
"lucide-react": "https://esm.sh/lucide-react@^0.563.0",
|
||||
"uuid": "https://esm.sh/uuid@^13.0.0"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-stone-100 dark:bg-black text-stone-900 dark:text-stone-100 antialiased selection:bg-primary-500 selection:text-white">
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "GreenLens",
|
||||
"description": "Eine faire, datenschutzfreundliche Pflanzen-Identifikations-App für den DACH-Markt. Offline-First und ohne Abo-Zwang.",
|
||||
"requestFramePermissions": [
|
||||
"camera"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "greenlens",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"@google/genai": "^1.38.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
|
||||
import { IdentificationResult, Language } from '../types';
|
||||
|
||||
interface DatabaseEntry extends IdentificationResult {
|
||||
imageUri: string; // Default image for the lexicon
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
const PLANT_DATABASE: Record<Language, DatabaseEntry[]> = {
|
||||
de: [
|
||||
{
|
||||
name: "Monstera",
|
||||
botanicalName: "Monstera deliciosa",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 7, light: "Halbschatten", temp: "18-24°C" },
|
||||
description: "Die Monstera Deliciosa, auch Fensterblatt genannt, ist bekannt für ihre großen, geteilten Blätter. Sie ist pflegeleicht und reinigt die Luft in Innenräumen effektiv.",
|
||||
imageUri: "https://images.unsplash.com/photo-1614594975525-e45190c55d0b?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["easy", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Birkenfeige",
|
||||
botanicalName: "Ficus benjamina",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 5, light: "Hell", temp: "16-24°C" },
|
||||
description: "Die Birkenfeige ist eine beliebte Zimmerpflanze mit eleganten, überhängenden Zweigen und glänzenden Blättern. Sie reagiert empfindlich auf Standortwechsel.",
|
||||
imageUri: "https://images.unsplash.com/photo-1509223197845-458d87318791?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["tree", "bright_light"]
|
||||
},
|
||||
{
|
||||
name: "Echeveria",
|
||||
botanicalName: "Echeveria elegans",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 14, light: "Sonnig", temp: "18-28°C" },
|
||||
description: "Diese Sukkulente bildet wunderschöne Rosetten und speichert Wasser in ihren dicken Blättern. Sie ist ideal für sonnige Fensterbänke und sehr pflegeleicht.",
|
||||
imageUri: "https://images.unsplash.com/photo-1520302669765-66b37fb890d7?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "easy", "small"]
|
||||
},
|
||||
{
|
||||
name: "Bogenhanf",
|
||||
botanicalName: "Sansevieria trifasciata",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 21, light: "Schatten bis Sonne", temp: "15-30°C" },
|
||||
description: "Der Bogenhanf ist fast unzerstörbar. Er kommt mit wenig Licht und Wasser aus und ist einer der besten Luftreiniger für das Schlafzimmer.",
|
||||
imageUri: "https://images.unsplash.com/photo-1620127530668-37c2275ae158?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "easy", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Echte Aloe",
|
||||
botanicalName: "Aloe vera",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 14, light: "Sonnig", temp: "20-30°C" },
|
||||
description: "Eine Heilpflanze, deren Gel bei Sonnenbrand hilft. Sie benötigt einen sehr hellen Standort und wenig Wasser.",
|
||||
imageUri: "https://images.unsplash.com/photo-1567689265771-828557d4766c?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "medicinal", "sun"]
|
||||
},
|
||||
{
|
||||
name: "Grünlilie",
|
||||
botanicalName: "Chlorophytum comosum",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 7, light: "Halbschatten", temp: "15-23°C" },
|
||||
description: "Die Grünlilie ist extrem anpassungsfähig und bildet schnell Ableger. Sie verzeiht Gießfehler und ist ideal für Anfänger.",
|
||||
imageUri: "https://images.unsplash.com/photo-1616766649725-b44c698308eb?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["easy", "hanging", "pet_friendly"]
|
||||
},
|
||||
{
|
||||
name: "Einblatt",
|
||||
botanicalName: "Spathiphyllum",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 5, light: "Halbschatten", temp: "18-25°C" },
|
||||
description: "Das Einblatt zeigt durch hängende Blätter an, wann es Wasser braucht. Es blüht auch bei weniger Licht wunderschön weiß.",
|
||||
imageUri: "https://images.unsplash.com/photo-1610496185876-06835a64627d?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["flowering", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Korbmarante",
|
||||
botanicalName: "Calathea",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 4, light: "Halbschatten", temp: "18-24°C" },
|
||||
description: "Calatheas sind bekannt für ihre gemusterten Blätter, die sich nachts zusammenfalten. Sie benötigen hohe Luftfeuchtigkeit.",
|
||||
imageUri: "https://images.unsplash.com/photo-1600869680373-b82fa72f8823?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["patterned", "pet_friendly", "high_humidity"]
|
||||
}
|
||||
],
|
||||
en: [
|
||||
{
|
||||
name: "Monstera",
|
||||
botanicalName: "Monstera deliciosa",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 7, light: "Partial Shade", temp: "18-24°C" },
|
||||
description: "The Monstera Deliciosa, also known as the Swiss Cheese Plant, is known for its large, split leaves. It is easy to care for and effectively purifies indoor air.",
|
||||
imageUri: "https://images.unsplash.com/photo-1614594975525-e45190c55d0b?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["easy", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Weeping Fig",
|
||||
botanicalName: "Ficus benjamina",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 5, light: "Bright", temp: "16-24°C" },
|
||||
description: "The Weeping Fig is a popular houseplant with elegant, drooping branches and glossy leaves. It is sensitive to changes in location.",
|
||||
imageUri: "https://images.unsplash.com/photo-1509223197845-458d87318791?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["tree", "bright_light"]
|
||||
},
|
||||
{
|
||||
name: "Mexican Snowball",
|
||||
botanicalName: "Echeveria elegans",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 14, light: "Sunny", temp: "18-28°C" },
|
||||
description: "This succulent forms beautiful rosettes and stores water in its thick leaves. It is ideal for sunny windowsills and very low maintenance.",
|
||||
imageUri: "https://images.unsplash.com/photo-1520302669765-66b37fb890d7?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "easy", "small"]
|
||||
},
|
||||
{
|
||||
name: "Snake Plant",
|
||||
botanicalName: "Sansevieria trifasciata",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 21, light: "Shade to Sun", temp: "15-30°C" },
|
||||
description: "The Snake Plant is nearly indestructible. It tolerates low light and drought and is one of the best air purifiers for the bedroom.",
|
||||
imageUri: "https://images.unsplash.com/photo-1620127530668-37c2275ae158?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "easy", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Aloe Vera",
|
||||
botanicalName: "Aloe vera",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 14, light: "Sunny", temp: "20-30°C" },
|
||||
description: "A medicinal plant whose gel helps with sunburn. It requires a very bright spot and little water.",
|
||||
imageUri: "https://images.unsplash.com/photo-1567689265771-828557d4766c?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "medicinal", "sun"]
|
||||
},
|
||||
{
|
||||
name: "Spider Plant",
|
||||
botanicalName: "Chlorophytum comosum",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 7, light: "Partial Shade", temp: "15-23°C" },
|
||||
description: "The Spider Plant is extremely adaptable and quickly forms offshoots. It forgives watering mistakes and is ideal for beginners.",
|
||||
imageUri: "https://images.unsplash.com/photo-1616766649725-b44c698308eb?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["easy", "hanging", "pet_friendly"]
|
||||
},
|
||||
{
|
||||
name: "Peace Lily",
|
||||
botanicalName: "Spathiphyllum",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 5, light: "Partial Shade", temp: "18-25°C" },
|
||||
description: "The Peace Lily shows when it needs water by drooping its leaves. It blooms beautifully white even in lower light.",
|
||||
imageUri: "https://images.unsplash.com/photo-1610496185876-06835a64627d?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["flowering", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Calathea",
|
||||
botanicalName: "Calathea",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 4, light: "Partial Shade", temp: "18-24°C" },
|
||||
description: "Calatheas are known for their patterned leaves that fold up at night. They require high humidity.",
|
||||
imageUri: "https://images.unsplash.com/photo-1600869680373-b82fa72f8823?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["patterned", "pet_friendly", "high_humidity"]
|
||||
}
|
||||
],
|
||||
es: [
|
||||
{
|
||||
name: "Costilla de Adán",
|
||||
botanicalName: "Monstera deliciosa",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 7, light: "Sombra Parcial", temp: "18-24°C" },
|
||||
description: "La Monstera Deliciosa es conocida por sus grandes hojas divididas. Es fácil de cuidar y purifica el aire interior de manera efectiva.",
|
||||
imageUri: "https://images.unsplash.com/photo-1614594975525-e45190c55d0b?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["easy", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Ficus Benjamina",
|
||||
botanicalName: "Ficus benjamina",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 5, light: "Brillante", temp: "16-24°C" },
|
||||
description: "El Ficus Benjamina es una planta de interior popular con ramas elegantes y caídas y hojas brillantes. Es sensible a los cambios de ubicación.",
|
||||
imageUri: "https://images.unsplash.com/photo-1509223197845-458d87318791?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["tree", "bright_light"]
|
||||
},
|
||||
{
|
||||
name: "Rosa de Alabastro",
|
||||
botanicalName: "Echeveria elegans",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 14, light: "Soleado", temp: "18-28°C" },
|
||||
description: "Esta suculenta forma hermosas rosetas y almacena agua en sus hojas gruesas. Es ideal para alféizares soleados y requiere muy poco mantenimiento.",
|
||||
imageUri: "https://images.unsplash.com/photo-1520302669765-66b37fb890d7?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "easy", "small"]
|
||||
},
|
||||
{
|
||||
name: "Lengua de Suegra",
|
||||
botanicalName: "Sansevieria trifasciata",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 21, light: "Sombra a Sol", temp: "15-30°C" },
|
||||
description: "La Sansevieria es casi indestructible. Tolera poca luz y sequía, y es uno de los mejores purificadores de aire para el dormitorio.",
|
||||
imageUri: "https://images.unsplash.com/photo-1620127530668-37c2275ae158?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "easy", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Aloe Vera",
|
||||
botanicalName: "Aloe vera",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 14, light: "Soleado", temp: "20-30°C" },
|
||||
description: "Una planta medicinal cuyo gel ayuda con las quemaduras solares. Requiere un lugar muy luminoso y poca agua.",
|
||||
imageUri: "https://images.unsplash.com/photo-1567689265771-828557d4766c?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "medicinal", "sun"]
|
||||
},
|
||||
{
|
||||
name: "Cinta",
|
||||
botanicalName: "Chlorophytum comosum",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 7, light: "Sombra Parcial", temp: "15-23°C" },
|
||||
description: "La Cinta es extremadamente adaptable y forma retoños rápidamente. Perdona los errores de riego y es ideal para principiantes.",
|
||||
imageUri: "https://images.unsplash.com/photo-1616766649725-b44c698308eb?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["easy", "hanging", "pet_friendly"]
|
||||
},
|
||||
{
|
||||
name: "Cuna de Moisés",
|
||||
botanicalName: "Spathiphyllum",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 5, light: "Sombra Parcial", temp: "18-25°C" },
|
||||
description: "La Cuna de Moisés muestra cuándo necesita agua al dejar caer sus hojas. Florece hermosamente en blanco incluso con poca luz.",
|
||||
imageUri: "https://images.unsplash.com/photo-1610496185876-06835a64627d?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["flowering", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Calathea",
|
||||
botanicalName: "Calathea",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 4, light: "Sombra Parcial", temp: "18-24°C" },
|
||||
description: "Las Calatheas son conocidas por sus hojas estampadas que se pliegan por la noche. Requieren alta humedad.",
|
||||
imageUri: "https://images.unsplash.com/photo-1600869680373-b82fa72f8823?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["patterned", "pet_friendly", "high_humidity"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const PlantDatabaseService = {
|
||||
getAllPlants: (lang: Language): DatabaseEntry[] => {
|
||||
return PLANT_DATABASE[lang] || PLANT_DATABASE['de'];
|
||||
},
|
||||
|
||||
searchPlants: (query: string, lang: Language): DatabaseEntry[] => {
|
||||
const plants = PLANT_DATABASE[lang] || PLANT_DATABASE['de'];
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
return plants.filter(p =>
|
||||
p.name.toLowerCase().includes(lowerQuery) ||
|
||||
p.botanicalName.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
},
|
||||
|
||||
getRandomPlant: (lang: Language): DatabaseEntry => {
|
||||
const plants = PLANT_DATABASE[lang] || PLANT_DATABASE['de'];
|
||||
return plants[Math.floor(Math.random() * plants.length)];
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
|
||||
import { IdentificationResult, Language } from '../types';
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
import { PlantDatabaseService } from './plantDatabaseService';
|
||||
|
||||
// Helper to convert base64 data URL to raw base64 string
|
||||
const cleanBase64 = (dataUrl: string) => {
|
||||
return dataUrl.split(',')[1];
|
||||
};
|
||||
|
||||
export const PlantRecognitionService = {
|
||||
identify: async (imageUri: string, lang: Language = 'de'): Promise<IdentificationResult> => {
|
||||
// 1. Check if we have an API Key. If so, use Gemini
|
||||
if (process.env.API_KEY) {
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
|
||||
// Dynamic prompt based on language
|
||||
const promptLang = lang === 'de' ? 'German' : lang === 'es' ? 'Spanish' : 'English';
|
||||
const promptText = `Identify this plant. Provide the common ${promptLang} name, the botanical name, a description (2 sentences) in ${promptLang}, an estimated confidence (0-1), and care info (water interval in days, light in ${promptLang}, temp). Response must be JSON.`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-3-pro-preview',
|
||||
contents: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/jpeg',
|
||||
data: cleanBase64(imageUri),
|
||||
},
|
||||
},
|
||||
{
|
||||
text: promptText
|
||||
}
|
||||
],
|
||||
},
|
||||
config: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
name: { type: Type.STRING },
|
||||
botanicalName: { type: Type.STRING },
|
||||
description: { type: Type.STRING },
|
||||
confidence: { type: Type.NUMBER },
|
||||
careInfo: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
waterIntervalDays: { type: Type.NUMBER },
|
||||
light: { type: Type.STRING },
|
||||
temp: { type: Type.STRING },
|
||||
},
|
||||
required: ["waterIntervalDays", "light", "temp"]
|
||||
}
|
||||
},
|
||||
required: ["name", "botanicalName", "confidence", "careInfo", "description"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (response.text) {
|
||||
return JSON.parse(response.text) as IdentificationResult;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Gemini analysis failed, falling back to mock.", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Mock Process (Fallback)
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
// Use the centralized database service for consistent mock results
|
||||
const randomResult = PlantDatabaseService.getRandomPlant(lang);
|
||||
|
||||
// Create a clean IdentificationResult without categories/imageUri if we want to strictly adhere to that type,
|
||||
// though Typescript allows extra props.
|
||||
// We simulate that the recognition might not be 100% like the db
|
||||
resolve({
|
||||
...randomResult,
|
||||
confidence: 0.85 + Math.random() * 0.14
|
||||
});
|
||||
}, 2500);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { Plant, Language } from '../types';
|
||||
|
||||
const STORAGE_KEY = 'greenlens_plants';
|
||||
const LANG_KEY = 'greenlens_language';
|
||||
|
||||
export const StorageService = {
|
||||
getPlants: (): Plant[] => {
|
||||
try {
|
||||
const json = localStorage.getItem(STORAGE_KEY);
|
||||
return json ? JSON.parse(json) : [];
|
||||
} catch (e) {
|
||||
console.error('Failed to load plants', e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
savePlant: (plant: Plant): void => {
|
||||
const plants = StorageService.getPlants();
|
||||
const updatedPlants = [plant, ...plants];
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPlants));
|
||||
},
|
||||
|
||||
deletePlant: (id: string): void => {
|
||||
const plants = StorageService.getPlants();
|
||||
const updatedPlants = plants.filter(p => p.id !== id);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPlants));
|
||||
},
|
||||
|
||||
updatePlant: (updatedPlant: Plant): void => {
|
||||
const plants = StorageService.getPlants();
|
||||
const index = plants.findIndex(p => p.id === updatedPlant.id);
|
||||
if (index !== -1) {
|
||||
plants[index] = updatedPlant;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(plants));
|
||||
}
|
||||
},
|
||||
|
||||
getLanguage: (): Language => {
|
||||
try {
|
||||
const lang = localStorage.getItem(LANG_KEY);
|
||||
return (lang as Language) || 'de';
|
||||
} catch (e) {
|
||||
return 'de';
|
||||
}
|
||||
},
|
||||
|
||||
saveLanguage: (lang: Language): void => {
|
||||
localStorage.setItem(LANG_KEY, lang);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
|
||||
export interface CareInfo {
|
||||
waterIntervalDays: number;
|
||||
light: string;
|
||||
temp: string;
|
||||
}
|
||||
|
||||
export interface Plant {
|
||||
id: string;
|
||||
name: string;
|
||||
botanicalName: string;
|
||||
imageUri: string;
|
||||
dateAdded: string; // Serialized Date
|
||||
careInfo: CareInfo;
|
||||
lastWatered: string; // Serialized Date
|
||||
wateringHistory?: string[]; // Array of serialized dates
|
||||
description?: string;
|
||||
notificationsEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IdentificationResult {
|
||||
name: string;
|
||||
botanicalName: string;
|
||||
confidence: number;
|
||||
careInfo: CareInfo;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export enum Tab {
|
||||
HOME = 'home',
|
||||
SEARCH = 'search',
|
||||
SETTINGS = 'settings',
|
||||
}
|
||||
|
||||
export type Language = 'de' | 'en' | 'es';
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
|
||||
import { Language } from '../types';
|
||||
|
||||
export const translations = {
|
||||
de: {
|
||||
// Tabs
|
||||
tabPlants: 'Pflanzen',
|
||||
tabSearch: 'Suche',
|
||||
tabProfile: 'Profil',
|
||||
|
||||
// Headers
|
||||
myPlants: 'Meine Pflanzen',
|
||||
searchTitle: 'Suche',
|
||||
settingsTitle: 'Einstellungen',
|
||||
|
||||
// Settings
|
||||
darkMode: 'Dark Mode',
|
||||
language: 'Sprache',
|
||||
|
||||
// Empty States / Info
|
||||
noPlants: 'Noch keine Pflanzen.',
|
||||
|
||||
// Filters
|
||||
allGood: 'Alles gut',
|
||||
toWater: 'Zu gießen',
|
||||
|
||||
// Search
|
||||
searchPlaceholder: 'Pflanzen suchen...',
|
||||
categories: 'Kategorien entdecken',
|
||||
resultsInPlants: 'Ergebnisse in "Meine Pflanzen"',
|
||||
noResults: 'Keine Pflanzen gefunden.',
|
||||
|
||||
// Categories
|
||||
catCareEasy: "Pflegeleicht",
|
||||
catSucculents: "Sukkulenten",
|
||||
catLowLight: "Wenig Licht",
|
||||
catPetFriendly: "Tierfreundlich",
|
||||
catAirPurifier: "Luftreiniger",
|
||||
catFlowering: "Blühend",
|
||||
|
||||
// Dictionary
|
||||
lexiconTitle: "Pflanzen-Lexikon",
|
||||
lexiconDesc: "Durchsuche unsere Datenbank und finde die perfekte Ergänzung für dein Zuhause.",
|
||||
lexiconSearchPlaceholder: "Lexikon durchsuchen...",
|
||||
browseLexicon: "Im Lexikon stöbern",
|
||||
backToSearch: "Zurück zur Suche",
|
||||
|
||||
// Misc
|
||||
comingSoon: 'Bald verfügbar',
|
||||
gallery: 'Galerie',
|
||||
help: 'Hilfe',
|
||||
scanner: 'Scanner',
|
||||
analyzing: 'Pflanze wird analysiert...',
|
||||
localProcessing: "Lokale Verarbeitung",
|
||||
|
||||
// Scan Stages
|
||||
scanStage1: "Bildqualität wird geprüft...",
|
||||
scanStage2: "Blattstrukturen werden analysiert...",
|
||||
scanStage3: "Abgleich mit Pflanzendatenbank...",
|
||||
|
||||
// Plant Card / Detail / Result
|
||||
result: "Ergebnis",
|
||||
match: "Übereinstimmung",
|
||||
careCheck: "Pflege-Check",
|
||||
showDetails: "Details anzeigen",
|
||||
hideDetails: "Details verbergen",
|
||||
dataSavedLocally: "Daten werden lokal gespeichert",
|
||||
addToPlants: "Zu meinen Pflanzen hinzufügen",
|
||||
aboutPlant: "Über die Pflanze",
|
||||
noDescription: "Keine Beschreibung verfügbar.",
|
||||
careTips: "Pflegehinweise",
|
||||
addedOn: "Hinzugefügt am",
|
||||
plantAddedSuccess: "Pflanze erfolgreich hinzugefügt",
|
||||
|
||||
// Detailed Care
|
||||
detailedCare: "Detaillierte Pflege",
|
||||
careTextWater: "Bodenfeuchtigkeit alle {0} Tage prüfen.",
|
||||
careTextLight: "Ideal für Standorte: {0}.",
|
||||
careTextTemp: "Wohlfühltemperatur: {0}.",
|
||||
|
||||
// Care Attributes
|
||||
water: "Gießen",
|
||||
light: "Licht",
|
||||
temp: "Temperatur",
|
||||
|
||||
// Care Values (UI Helper)
|
||||
waterModerate: "Mäßig",
|
||||
waterLittle: "Wenig",
|
||||
waterEveryXDays: "Alle {0} Tage",
|
||||
waterToday: "Heute gießen",
|
||||
inXDays: "In {0} Tagen",
|
||||
nextWatering: "Nächstes Gießen: {0}",
|
||||
days: "Tage",
|
||||
|
||||
// Actions
|
||||
delete: "Löschen",
|
||||
edit: "Bearbeiten",
|
||||
share: "Teilen",
|
||||
waterNow: "Jetzt gießen",
|
||||
watered: "Gegossen",
|
||||
wateredSuccess: "Pflanze wurde gegossen!",
|
||||
plantDeleted: "Pflanze entfernt.",
|
||||
deleteConfirmTitle: "Pflanze löschen?",
|
||||
deleteConfirmMessage: "Möchtest du diese Pflanze wirklich aus deiner Sammlung entfernen? Dies kann nicht rückgängig gemacht werden.",
|
||||
cancel: "Abbrechen",
|
||||
confirm: "Löschen",
|
||||
lastWateredDate: "Zuletzt gegossen: {0}",
|
||||
|
||||
// History
|
||||
wateringHistory: "Gießhistorie",
|
||||
noHistory: "Noch keine Einträge.",
|
||||
|
||||
// Reminder
|
||||
reminder: "Erinnerung",
|
||||
reminderDesc: "Benachrichtigung am Stichtag",
|
||||
reminderOn: "Aktiviert",
|
||||
reminderOff: "Deaktiviert",
|
||||
reminderPermissionNeeded: "Berechtigung für Benachrichtigungen erforderlich.",
|
||||
},
|
||||
en: {
|
||||
tabPlants: 'Plants',
|
||||
tabSearch: 'Search',
|
||||
tabProfile: 'Profile',
|
||||
myPlants: 'My Plants',
|
||||
searchTitle: 'Search',
|
||||
settingsTitle: 'Settings',
|
||||
darkMode: 'Dark Mode',
|
||||
language: 'Language',
|
||||
noPlants: 'No plants yet.',
|
||||
allGood: 'All good',
|
||||
toWater: 'To water',
|
||||
searchPlaceholder: 'Search plants...',
|
||||
categories: 'Discover Categories',
|
||||
resultsInPlants: 'Results in "My Plants"',
|
||||
noResults: 'No plants found.',
|
||||
|
||||
catCareEasy: "Easy Care",
|
||||
catSucculents: "Succulents",
|
||||
catLowLight: "Low Light",
|
||||
catPetFriendly: "Pet Friendly",
|
||||
catAirPurifier: "Air Purifier",
|
||||
catFlowering: "Flowering",
|
||||
|
||||
lexiconTitle: "Plant Encyclopedia",
|
||||
lexiconDesc: "Browse our database and find the perfect addition for your home.",
|
||||
lexiconSearchPlaceholder: "Search encyclopedia...",
|
||||
browseLexicon: "Browse Encyclopedia",
|
||||
backToSearch: "Back to Search",
|
||||
|
||||
comingSoon: 'Coming Soon',
|
||||
gallery: 'Gallery',
|
||||
help: 'Help',
|
||||
scanner: 'Scanner',
|
||||
analyzing: 'Analyzing plant...',
|
||||
localProcessing: "Local Processing",
|
||||
|
||||
// Scan Stages
|
||||
scanStage1: "Checking image quality...",
|
||||
scanStage2: "Analyzing leaf structures...",
|
||||
scanStage3: "Matching with plant database...",
|
||||
|
||||
result: "Result",
|
||||
match: "Match",
|
||||
careCheck: "Care Check",
|
||||
showDetails: "Show Details",
|
||||
hideDetails: "Hide Details",
|
||||
dataSavedLocally: "Data is saved locally",
|
||||
addToPlants: "Add to my plants",
|
||||
aboutPlant: "About the plant",
|
||||
noDescription: "No description available.",
|
||||
careTips: "Care Tips",
|
||||
addedOn: "Added on",
|
||||
plantAddedSuccess: "Plant successfully added",
|
||||
|
||||
detailedCare: "Detailed Care",
|
||||
careTextWater: "Check soil moisture every {0} days.",
|
||||
careTextLight: "Ideal location: {0}.",
|
||||
careTextTemp: "Ideal temperature: {0}.",
|
||||
|
||||
water: "Water",
|
||||
light: "Light",
|
||||
temp: "Temperature",
|
||||
|
||||
waterModerate: "Moderate",
|
||||
waterLittle: "Little",
|
||||
waterEveryXDays: "Every {0} days",
|
||||
waterToday: "Water today",
|
||||
inXDays: "In {0} days",
|
||||
nextWatering: "Next watering: {0}",
|
||||
days: "Days",
|
||||
|
||||
delete: "Delete",
|
||||
edit: "Edit",
|
||||
share: "Share",
|
||||
waterNow: "Water Now",
|
||||
watered: "Watered",
|
||||
wateredSuccess: "Plant watered!",
|
||||
plantDeleted: "Plant removed.",
|
||||
deleteConfirmTitle: "Delete plant?",
|
||||
deleteConfirmMessage: "Do you really want to remove this plant from your collection? This cannot be undone.",
|
||||
cancel: "Cancel",
|
||||
confirm: "Delete",
|
||||
lastWateredDate: "Last watered: {0}",
|
||||
|
||||
// History
|
||||
wateringHistory: "Watering History",
|
||||
noHistory: "No entries yet.",
|
||||
|
||||
// Reminder
|
||||
reminder: "Reminder",
|
||||
reminderDesc: "Notification on due date",
|
||||
reminderOn: "Enabled",
|
||||
reminderOff: "Disabled",
|
||||
reminderPermissionNeeded: "Notification permission required.",
|
||||
},
|
||||
es: {
|
||||
tabPlants: 'Plantas',
|
||||
tabSearch: 'Buscar',
|
||||
tabProfile: 'Perfil',
|
||||
myPlants: 'Mis Plantas',
|
||||
searchTitle: 'Buscar',
|
||||
settingsTitle: 'Ajustes',
|
||||
darkMode: 'Modo Oscuro',
|
||||
language: 'Idioma',
|
||||
noPlants: 'Aún no hay plantas.',
|
||||
allGood: 'Todo bien',
|
||||
toWater: 'Regar',
|
||||
searchPlaceholder: 'Buscar plantas...',
|
||||
categories: 'Descubrir Categorías',
|
||||
resultsInPlants: 'Resultados en "Mis Plantas"',
|
||||
noResults: 'No se encontraron plantas.',
|
||||
|
||||
catCareEasy: "Fácil Cuidado",
|
||||
catSucculents: "Suculentas",
|
||||
catLowLight: "Poca Luz",
|
||||
catPetFriendly: "Pet Friendly",
|
||||
catAirPurifier: "Purificador",
|
||||
catFlowering: "Con Flores",
|
||||
|
||||
lexiconTitle: "Enciclopedia",
|
||||
lexiconDesc: "Explora nuestra base de datos y encuentra la adición perfecta para tu hogar.",
|
||||
lexiconSearchPlaceholder: "Buscar en enciclopedia...",
|
||||
browseLexicon: "Explorar Enciclopedia",
|
||||
backToSearch: "Volver a Buscar",
|
||||
|
||||
comingSoon: 'Próximamente',
|
||||
gallery: 'Galería',
|
||||
help: 'Ayuda',
|
||||
scanner: 'Escáner',
|
||||
analyzing: 'Analizando planta...',
|
||||
localProcessing: "Procesamiento Local",
|
||||
|
||||
// Scan Stages
|
||||
scanStage1: "Verificando calidad de imagen...",
|
||||
scanStage2: "Analizando estructuras...",
|
||||
scanStage3: "Comparando con base de datos...",
|
||||
|
||||
result: "Resultado",
|
||||
match: "Coincidencia",
|
||||
careCheck: "Chequeo de Cuidados",
|
||||
showDetails: "Ver Detalles",
|
||||
hideDetails: "Ocultar Detalles",
|
||||
dataSavedLocally: "Datos guardados localmente",
|
||||
addToPlants: "Añadir a mis plantas",
|
||||
aboutPlant: "Sobre la planta",
|
||||
noDescription: "Sin descripción disponible.",
|
||||
careTips: "Consejos de Cuidado",
|
||||
addedOn: "Añadido el",
|
||||
plantAddedSuccess: "Planta añadida con éxito",
|
||||
|
||||
detailedCare: "Cuidado Detallado",
|
||||
careTextWater: "Revisar humedad cada {0} días.",
|
||||
careTextLight: "Ubicación ideal: {0}.",
|
||||
careTextTemp: "Temperatura ideal: {0}.",
|
||||
|
||||
water: "Riego",
|
||||
light: "Luz",
|
||||
temp: "Temperatura",
|
||||
|
||||
waterModerate: "Moderado",
|
||||
waterLittle: "Poco",
|
||||
waterEveryXDays: "Cada {0} días",
|
||||
waterToday: "Regar hoy",
|
||||
inXDays: "En {0} días",
|
||||
nextWatering: "Próximo riego: {0}",
|
||||
days: "Días",
|
||||
|
||||
delete: "Eliminar",
|
||||
edit: "Editar",
|
||||
share: "Compartir",
|
||||
waterNow: "Regar ahora",
|
||||
watered: "Regada",
|
||||
wateredSuccess: "¡Planta regada!",
|
||||
plantDeleted: "Planta eliminada.",
|
||||
deleteConfirmTitle: "¿Eliminar planta?",
|
||||
deleteConfirmMessage: "¿Realmente quieres eliminar esta planta de tu colección? Esto no se puede deshacer.",
|
||||
cancel: "Cancelar",
|
||||
confirm: "Eliminar",
|
||||
lastWateredDate: "Último riego: {0}",
|
||||
|
||||
// History
|
||||
wateringHistory: "Historial de Riego",
|
||||
noHistory: "Sin entradas aún.",
|
||||
|
||||
// Reminder
|
||||
reminder: "Recordatorio",
|
||||
reminderDesc: "Notificación el día de riego",
|
||||
reminderOn: "Activado",
|
||||
reminderOff: "Desactivado",
|
||||
reminderPermissionNeeded: "Permiso de notificación requerido.",
|
||||
}
|
||||
};
|
||||
|
||||
export const getTranslation = (lang: Language) => translations[lang];
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import path from 'path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
plugins: [react()],
|
||||
define: {
|
||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Loading…
Reference in New Issue