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.
681 lines
29 KiB
TypeScript
681 lines
29 KiB
TypeScript
|
|
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;
|